mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-08 00:03:07 +00:00
* feat: add Azure Event Grid triggers (EE)
Introduces a new enterprise trigger kind `azure` that supports three
modes via a single unified trigger type:
- basic_push: Azure Event Grid basic — custom topics, system topics
(Storage, Resource Manager, Key Vault, etc.), domains (push only)
- namespace_push: Event Grid Namespace topics (CloudEvents over HTTP push)
- namespace_pull: Event Grid Namespace topics (HTTP pull with lock-token
ack/reject for dead-lettering)
Auth uses a Service Principal resource (tenant_id, client_id,
client_secret, subscription_id). Subscriptions are created in
CloudEvents 1.0 schema so the push webhook handler and the pull listener
share one payload parser.
Backend
- New crate `windmill-trigger-azure` (OSS stubs + EE impl symlinked from
windmill-ee-private)
- Migration `azure_trigger` table with CHECK constraints enforcing
mode/columns coherence
- `TriggerKind::Azure`, `JobTriggerKind::Azure`,
`DeployedObject::AzureTrigger` variants
- Push route `/api/azure/w/{workspace}/*path` handles classic
Event Grid SubscriptionValidation handshake and CloudEvents 1.0
abuse-protection OPTIONS handshake
- Optional inbound JWT validation (audience check only for v1)
- Feature flag `azure_trigger` propagated through windmill-api,
windmill-store (resource helper), and added to ee_core
Frontend
- `triggers/azure/` editor with mode toggle (basic/namespace-push/
namespace-pull) and per-mode config (topic ARM id / namespace +
topic name / subscription / filters / push auth / pull options)
- Registered in icon map, display names, save functions, badge,
wrapper, editor, add-trigger menu
OpenAPI
- `AzureTrigger`, `AzureTriggerData`, `AzureMode`,
`AzureSubscriptionMode`, `AzureDeliveryConfig`, `TestAzureConnection`
schemas; `/azure_triggers/*` endpoints; client regenerated
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: update ee-repo-ref to eaa7c3a9cb37a9ccc93f10a2535d929365acd2d8
This commit updates the EE repository reference after PR #541 was merged in windmill-ee-private.
Previous ee-repo-ref: 9689014e8c12c36c1059fd8fa5758d550b8b8bc9
New ee-repo-ref: eaa7c3a9cb37a9ccc93f10a2535d929365acd2d8
Automated by sync-ee-ref workflow.
* feat(azure-trigger): secret-auth push, ARM discovery, capture isolation, CLI + parity
Frontend:
- Split mode selector into Namespace/Basic + Pull/Push
- ARM resource dropdowns (namespaces, Basic topics, namespace topics)
populated from the service principal; cascade with stale-selection
reset on SP / edition change
- Remove stale authenticate toggle + audience input (server-managed
push_auth_config has replaced them)
- Azure listing page: "Create from template" button; "Also delete Azure
subscription" toggle in the delete modal; simplified trigger label
falling back to path
- AzureCapture.svelte: "Test subscription name" with -wm-capture suffix
- CompareWorkspaces.svelte: wire Azure for fork/compare
- Drop Trigger-deployed/event-loss warning (capture subscription is
isolated with -wm-capture)
Backend:
- Shared-secret push auth (see EE crate for detail)
- JSONB push_auth_config column (renamed from delivery_config), #[serde(skip)]
so clients/CLI/exports never see it
- Drop redundant enabled column; mode supersedes
- Azure capture infra: AzureTriggerConfig + set_azure_trigger_config +
azure_payload route + TriggerKind::Azure arm; PT15M queue TTL on
capture subscriptions so they bound storage after tab close
- Granular ACLs, users offboarding, trash, git-sync deployed-object:
all include azure_trigger
CLI:
- Add azure to TRIGGER_TYPES, pushObj dispatch, getTypeStrFromPath,
trigger commands (get/update/create/list/template), sync delete
switch + regex; e2e test for `trigger new --kind azure`
- system_prompts: SCHEMA_MAPPINGS + schema_names include AzureTrigger;
auto-generated/* regenerated
Skill:
- .claude/skills/adding-a-trigger/ checklist covering every file that
needs editing when wiring a new trigger type (learned from this PR)
ee-repo-ref bumped to b0e490cbf3724b7b64c6a5b010e3bdf24acd873c.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(azure-trigger): ci — ShareModal Kind + regenerated system_prompts
- frontend/src/lib/components/ShareModal.svelte: add 'azure_trigger'
to the Kind type so the listing page's "Permissions" action compiles
(ts2345 — caught by npm_check on CI, missed by fast-check locally).
- system_prompts/auto-generated/: regenerate to drop the stale
delivery_config / AzureDeliveryConfig fields from the Azure schema
(check-freshness on CI).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(azure-trigger): use workspace constant_time_eq crate
Drop hand-rolled constant-time compare in favour of the workspace
constant_time_eq crate (same one used by http_trigger_auth).
ee-repo-ref bumped to 9659382d47286e7f7f66d01b6f5dd8d4ed34848b.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(azure-trigger): pass placeholder + disabled via inputProps
`TextInput`'s `placeholder` and `disabled` go through its `inputProps`
prop — CI's `npm run check` caught the stale top-level passing that
`npm run check:fast` missed. Align with the DefaultEmailConfigSection
pattern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(azure-trigger): correct LATEST_GIT_SYNC_SCRIPT_PATH version to 28213
The hub deploy of the azure-aware sync-script is version 28213, not
28214. Backend was pinning a non-existent hub script, which broke the
git_sync_e2e suite (every deploy's sync step 404'd).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(azure-trigger): add azure_triggers to token scope selector + skill
- windmill-api/src/token.rs: `build_trigger_scope_domains` was missing
`("azure_triggers", "Azure Event Grid")`, so the CreateToken UI's scope
selector didn't surface azure_triggers:read/write. Backend already had
`ScopeDomain::AzureTriggers` wired (scopes.rs), this just exposes it.
- .claude/skills/adding-a-trigger/SKILL.md: capture both scope-related
files under the hardcoded-arrays section so future triggers don't miss
the UI surface.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(adding-a-trigger-skill): clarify token.rs scope effect
Not a regression — nothing was working before. Skipping TRIGGER_DOMAINS
just means the scope works via API/CLI but has no UI checkbox.
* docs(adding-a-trigger-skill): trim token.rs bullet
* fix(azure-trigger): regen openapi-deref + swap textarea for TextInput
- Run build_openapi.sh to regenerate openapi-deref.{yaml,json} with the
12 azure_triggers paths + schemas. These files are served by the
runtime (include_str! in windmill-api/src/lib.rs) to external SDK
consumers; without this regen the new endpoints wouldn't be advertised.
- Replace the raw <textarea> for event type filters with the
design-system TextInput in textarea mode (frontend/CLAUDE.md bans raw
HTML elements).
Addresses cubic + claude PR review items.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
357 lines
9.6 KiB
Svelte
357 lines
9.6 KiB
Svelte
<script lang="ts">
|
|
import TableCustom from './TableCustom.svelte'
|
|
|
|
import {
|
|
GroupService,
|
|
UserService,
|
|
GranularAclService,
|
|
ResourceService,
|
|
FolderService
|
|
} from '$lib/gen'
|
|
import type { Folder } from '$lib/gen/types.gen'
|
|
import { createEventDispatcher } from 'svelte'
|
|
import { userStore, workspaceStore } from '$lib/stores'
|
|
import { Alert, Button, Drawer } from './common'
|
|
import DrawerContent from './common/drawer/DrawerContent.svelte'
|
|
import { sendUserToast } from '$lib/toast'
|
|
import { isOwner } from '$lib/utils'
|
|
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
|
|
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
|
|
import Select from './select/Select.svelte'
|
|
import { safeSelectItems } from './select/utils.svelte'
|
|
import Toggle from './Toggle.svelte'
|
|
import { Trash } from 'lucide-svelte'
|
|
|
|
const dispatch = createEventDispatcher()
|
|
|
|
type Kind =
|
|
| 'script'
|
|
| 'group_'
|
|
| 'resource'
|
|
| 'schedule'
|
|
| 'variable'
|
|
| 'flow'
|
|
| 'app'
|
|
| 'raw_app'
|
|
| 'http_trigger'
|
|
| 'websocket_trigger'
|
|
| 'kafka_trigger'
|
|
| 'nats_trigger'
|
|
| 'mqtt_trigger'
|
|
| 'sqs_trigger'
|
|
| 'postgres_trigger'
|
|
| 'gcp_trigger'
|
|
| 'azure_trigger'
|
|
| 'email_trigger'
|
|
| 'volume'
|
|
let kind: Kind
|
|
|
|
let path: string = $state('')
|
|
|
|
let ownerKind: 'user' | 'group' = $state('user')
|
|
let owner: string = $state('')
|
|
|
|
let newOwner: string = $derived.by(
|
|
() => owner && [ownerKind === 'group' ? 'g' : 'u', owner].join('/')
|
|
)
|
|
let write: boolean = false
|
|
let acls: [string, boolean][] = $state([])
|
|
let groups: String[] = $state([])
|
|
let usernames: string[] = $state([])
|
|
|
|
let drawer: Drawer | undefined = $state()
|
|
|
|
let linkedVarPaths: string[] = $state([])
|
|
let alsoApplyToLinked: boolean = $state(true)
|
|
|
|
let defaultPerms: { label: string; write: boolean }[] = $state([])
|
|
let defaultPermsLabel: string = $state('')
|
|
|
|
async function loadDefaultPerms() {
|
|
const currentPath = path
|
|
const parts = currentPath.split('/')
|
|
if (parts[0] === 'f' && parts.length >= 2) {
|
|
const folderName = parts[1]
|
|
defaultPermsLabel = `Folder f/${folderName} permissions`
|
|
try {
|
|
const folder: Folder = await FolderService.getFolder({
|
|
workspace: $workspaceStore!,
|
|
name: folderName
|
|
})
|
|
if (path !== currentPath) return
|
|
const perms: { label: string; write: boolean }[] = []
|
|
for (const owner of folder.owners) {
|
|
perms.push({ label: owner, write: true })
|
|
}
|
|
for (const [owner, write] of Object.entries(folder.extra_perms ?? {})) {
|
|
if (!folder.owners.includes(owner)) {
|
|
perms.push({ label: owner, write })
|
|
}
|
|
}
|
|
defaultPerms = perms
|
|
} catch {
|
|
if (path !== currentPath) return
|
|
defaultPerms = []
|
|
}
|
|
} else if (parts[0] === 'u' && parts.length >= 2) {
|
|
defaultPermsLabel = `User u/${parts[1]} permissions`
|
|
defaultPerms = [{ label: `u/${parts[1]}`, write: true }]
|
|
} else if (parts[0] === 'g' && parts.length >= 2) {
|
|
defaultPermsLabel = `Group g/${parts[1]} permissions`
|
|
defaultPerms = [{ label: `g/${parts[1]}`, write: true }]
|
|
} else {
|
|
defaultPerms = []
|
|
defaultPermsLabel = ''
|
|
}
|
|
}
|
|
|
|
function collectVarRefs(value: unknown): string[] {
|
|
const paths: string[] = []
|
|
function walk(v: unknown) {
|
|
if (typeof v === 'string' && v.startsWith('$var:')) {
|
|
paths.push(v.substring('$var:'.length))
|
|
} else if (Array.isArray(v)) {
|
|
v.forEach(walk)
|
|
} else if (v && typeof v === 'object') {
|
|
Object.values(v).forEach(walk)
|
|
}
|
|
}
|
|
walk(value)
|
|
return [...new Set(paths)]
|
|
}
|
|
|
|
async function loadLinkedVarPaths() {
|
|
if (kind !== 'resource') {
|
|
linkedVarPaths = []
|
|
return
|
|
}
|
|
const currentPath = path
|
|
try {
|
|
const resource = await ResourceService.getResource({
|
|
workspace: $workspaceStore!,
|
|
path: currentPath
|
|
})
|
|
if (path !== currentPath) return
|
|
linkedVarPaths = collectVarRefs(resource.value)
|
|
} catch {
|
|
if (path !== currentPath) return
|
|
linkedVarPaths = []
|
|
}
|
|
}
|
|
|
|
let own = $state(false)
|
|
export async function openDrawer(newPath: string, kind_l: Kind, isOwnerOverride?: boolean) {
|
|
path = newPath
|
|
kind = kind_l
|
|
alsoApplyToLinked = true
|
|
loadAcls()
|
|
loadGroups()
|
|
loadUsernames()
|
|
loadLinkedVarPaths()
|
|
loadDefaultPerms()
|
|
if (isOwnerOverride !== undefined) {
|
|
own = isOwnerOverride
|
|
} else {
|
|
loadOwner()
|
|
}
|
|
drawer?.openDrawer()
|
|
}
|
|
|
|
async function loadOwner() {
|
|
own = isOwner(path, $userStore!, $workspaceStore!)
|
|
}
|
|
|
|
async function loadAcls() {
|
|
acls = Object.entries(
|
|
await GranularAclService.getGranularAcls({ workspace: $workspaceStore!, path, kind })
|
|
)
|
|
}
|
|
|
|
async function loadGroups(): Promise<void> {
|
|
groups = await GroupService.listGroupNames({ workspace: $workspaceStore! })
|
|
}
|
|
|
|
async function loadUsernames(): Promise<void> {
|
|
usernames = await UserService.listUsernames({ workspace: $workspaceStore! })
|
|
}
|
|
|
|
async function deleteAcl(owner: string) {
|
|
try {
|
|
await GranularAclService.removeGranularAcls({
|
|
workspace: $workspaceStore!,
|
|
path,
|
|
kind,
|
|
requestBody: { owner }
|
|
})
|
|
if (alsoApplyToLinked) {
|
|
for (const varPath of linkedVarPaths) {
|
|
try {
|
|
await GranularAclService.removeGranularAcls({
|
|
workspace: $workspaceStore!,
|
|
path: varPath,
|
|
kind: 'variable',
|
|
requestBody: { owner }
|
|
})
|
|
} catch (err) {
|
|
sendUserToast(`Failed to update variable ${varPath}: ${err}`, true)
|
|
}
|
|
}
|
|
}
|
|
loadAcls()
|
|
dispatch('change', { path, kind })
|
|
} catch (err) {
|
|
sendUserToast(err.toString(), true)
|
|
}
|
|
}
|
|
|
|
async function addAcl(owner: string, write: boolean) {
|
|
await GranularAclService.addGranularAcls({
|
|
workspace: $workspaceStore!,
|
|
path,
|
|
kind,
|
|
requestBody: { owner, write }
|
|
})
|
|
if (alsoApplyToLinked) {
|
|
for (const varPath of linkedVarPaths) {
|
|
try {
|
|
await GranularAclService.addGranularAcls({
|
|
workspace: $workspaceStore!,
|
|
path: varPath,
|
|
kind: 'variable',
|
|
requestBody: { owner, write }
|
|
})
|
|
} catch (err) {
|
|
sendUserToast(`Failed to update variable ${varPath}: ${err}`, true)
|
|
}
|
|
}
|
|
}
|
|
loadAcls()
|
|
dispatch('change', { path, kind })
|
|
}
|
|
</script>
|
|
|
|
<Drawer bind:this={drawer}>
|
|
<DrawerContent title="Permissions for {path}" on:close={drawer?.closeDrawer}>
|
|
<div class="flex flex-col gap-4">
|
|
{#if defaultPerms.length > 0}
|
|
<div class="flex flex-col gap-1">
|
|
<span class="text-sm font-semibold text-emphasis">{defaultPermsLabel}</span>
|
|
{#each defaultPerms as perm (perm.label)}
|
|
<div class="flex items-center justify-between text-xs text-primary">
|
|
<span>{perm.label}</span>
|
|
<span class="text-tertiary">{perm.write ? 'Writer' : 'Viewer'}</span>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
<div class="flex flex-col gap-2">
|
|
<span class="text-sm font-semibold text-emphasis"
|
|
>Extra permissions ({acls?.length ?? 0})</span
|
|
>
|
|
{#if linkedVarPaths.length > 0}
|
|
<div class="flex flex-col gap-1.5 p-3 border rounded bg-surface-secondary text-xs">
|
|
<Toggle
|
|
size="xs"
|
|
bind:checked={alsoApplyToLinked}
|
|
options={{ right: 'Also apply to linked variables' }}
|
|
/>
|
|
<ul class="text-2xs text-secondary list-disc ml-4">
|
|
{#each linkedVarPaths as varPath (varPath)}
|
|
<li>{varPath}</li>
|
|
{/each}
|
|
</ul>
|
|
</div>
|
|
{/if}
|
|
{#if !own}
|
|
<Alert type="warning" title="Not owner"
|
|
>Since you do not own this item, you cannot modify its permission</Alert
|
|
>
|
|
{/if}
|
|
<div>
|
|
{#if own}
|
|
<div class="flex flex-row flex-wrap gap-2 items-center">
|
|
<div>
|
|
<ToggleButtonGroup bind:selected={ownerKind} on:selected={() => (owner = '')}>
|
|
{#snippet children({ item })}
|
|
<ToggleButton value="user" label="User" {item} />
|
|
<ToggleButton value="group" label="Group" {item} />
|
|
{/snippet}
|
|
</ToggleButtonGroup>
|
|
</div>
|
|
{#key ownerKind}
|
|
<Select
|
|
items={safeSelectItems(
|
|
(ownerKind === 'user' ? usernames : groups).map((x) => x.toString())
|
|
)}
|
|
bind:value={owner}
|
|
class="grow min-w-48"
|
|
/>
|
|
{/key}
|
|
<Button
|
|
size="lg"
|
|
variant="accent"
|
|
disabled={!newOwner}
|
|
on:click={() => addAcl(newOwner, write)}>Add permission</Button
|
|
>
|
|
</div>
|
|
{/if}
|
|
{#if acls?.length > 0}
|
|
<TableCustom>
|
|
{#snippet headerRow()}
|
|
<tr>
|
|
<th>owner</th>
|
|
<th></th>
|
|
<th></th>
|
|
</tr>
|
|
{/snippet}
|
|
{#snippet body()}
|
|
<tbody>
|
|
{#each acls as [owner, write]}
|
|
<tr>
|
|
<td>{owner}</td>
|
|
<td
|
|
>{#if own}
|
|
<div>
|
|
<ToggleButtonGroup
|
|
selected={write ? 'writer' : 'viewer'}
|
|
on:selected={async (e) => {
|
|
const role = e.detail
|
|
if (role == 'writer') {
|
|
await addAcl(owner, true)
|
|
} else {
|
|
await addAcl(owner, false)
|
|
}
|
|
loadAcls()
|
|
}}
|
|
>
|
|
{#snippet children({ item })}
|
|
<ToggleButton value="viewer" small label="Viewer" {item} />
|
|
<ToggleButton value="writer" small label="Writer" {item} />
|
|
{/snippet}
|
|
</ToggleButtonGroup>
|
|
</div>
|
|
{:else}{write}{/if}</td
|
|
>
|
|
<td>
|
|
{#if own}
|
|
<Button
|
|
variant="default"
|
|
destructive
|
|
size="xs"
|
|
on:click={() => deleteAcl(owner)}
|
|
startIcon={{ icon: Trash }}
|
|
/>
|
|
{/if}
|
|
</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
{/snippet}
|
|
</TableCustom>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div></DrawerContent
|
|
>
|
|
</Drawer>
|