feat: allow non-admins to create and edit HTTP triggers (#8810)

* feat: allow non-admin users to create HTTP triggers with forced workspaced routes

Non-admin users can now create and fully edit HTTP triggers, but are forced
to use workspaced routes (workspace-prefixed URLs). Instance-wide routes
remain admin-only to prevent cross-workspace URL conflicts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add missing RLS INSERT/DELETE policies for http_trigger table

Non-admin users were blocked by row-level security when creating HTTP triggers.
Added INSERT, DELETE, see_own, and see_member policies matching other trigger tables.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: allow user paths for HTTP triggers

Remove the hideUser restriction on the Path component so HTTP triggers
can be created under user paths (u/username/...) in addition to folder paths.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: remove added note from instance settings description

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: allow non-admins to edit non-workspaced routes without changing route config

Non-admins can now open and edit existing non-workspaced HTTP triggers
(created by admins) as long as they don't modify route_path, http_method,
or workspaced_route. The workspaced prefix is only forced on new triggers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: allow non-admins to change route_path on workspaced routes

The prevent_route_path_change DB trigger blocked all route_path changes
for windmill_user, even on workspaced routes. Now only instance-wide
(non-workspaced) routes are protected.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add explicit GRANT and force workspaced routes in OpenAPI generator

- Add explicit GRANT INSERT, DELETE on http_trigger to windmill_user
  for safety on customer instances
- Force workspaced_route: true for non-admins in OpenAPI route generator

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-04-13 20:43:49 +02:00
committed by GitHub
parent 64c58c824f
commit 9fb78164b4
9 changed files with 157 additions and 59 deletions

View File

@@ -0,0 +1,21 @@
DROP POLICY IF EXISTS see_folder_extra_perms_user_insert ON http_trigger;
DROP POLICY IF EXISTS see_folder_extra_perms_user_delete ON http_trigger;
DROP POLICY IF EXISTS see_extra_perms_user_insert ON http_trigger;
DROP POLICY IF EXISTS see_extra_perms_user_delete ON http_trigger;
DROP POLICY IF EXISTS see_extra_perms_groups_insert ON http_trigger;
DROP POLICY IF EXISTS see_extra_perms_groups_delete ON http_trigger;
DROP POLICY IF EXISTS see_own ON http_trigger;
DROP POLICY IF EXISTS see_member ON http_trigger;
REVOKE INSERT, DELETE ON http_trigger FROM windmill_user;
-- Restore original prevent_route_path_change that blocks all non-admin route_path changes
CREATE OR REPLACE FUNCTION prevent_route_path_change()
RETURNS TRIGGER AS $$
BEGIN
IF CURRENT_USER = 'windmill_user' AND NEW.route_path <> OLD.route_path THEN
RAISE EXCEPTION 'Modification of route_path is only allowed by admins';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;

View File

@@ -0,0 +1,53 @@
-- Add missing INSERT and DELETE RLS policies for http_trigger table.
-- These are needed now that non-admin users can create HTTP triggers (with forced workspaced routes).
-- All other trigger tables already have these policies.
-- Ensure windmill_user has INSERT and DELETE privileges (original migration only granted SELECT, UPDATE).
-- Note: 20250205131523_grant_all_in_current_schema also grants ALL, but we add explicit grants for safety.
GRANT INSERT, DELETE ON http_trigger TO windmill_user;
-- Folder-based policies
CREATE POLICY see_folder_extra_perms_user_insert ON http_trigger FOR INSERT TO windmill_user
WITH CHECK (SPLIT_PART(http_trigger.path, '/', 1) = 'f' AND SPLIT_PART(http_trigger.path, '/', 2) = any(regexp_split_to_array(current_setting('session.folders_write'), ',')::text[]));
CREATE POLICY see_folder_extra_perms_user_delete ON http_trigger FOR DELETE TO windmill_user
USING (SPLIT_PART(http_trigger.path, '/', 1) = 'f' AND SPLIT_PART(http_trigger.path, '/', 2) = any(regexp_split_to_array(current_setting('session.folders_write'), ',')::text[]));
-- User extra_perms policies
CREATE POLICY see_extra_perms_user_insert ON http_trigger FOR INSERT TO windmill_user
WITH CHECK ((extra_perms ->> CONCAT('u/', current_setting('session.user')))::boolean);
CREATE POLICY see_extra_perms_user_delete ON http_trigger FOR DELETE TO windmill_user
USING ((extra_perms ->> CONCAT('u/', current_setting('session.user')))::boolean);
-- Group extra_perms policies
CREATE POLICY see_extra_perms_groups_insert ON http_trigger FOR INSERT TO windmill_user
WITH CHECK (exists(
SELECT key, value FROM jsonb_each_text(extra_perms)
WHERE SPLIT_PART(key, '/', 1) = 'g' AND key = ANY(regexp_split_to_array(current_setting('session.pgroups'), ',')::text[])
AND value::boolean));
CREATE POLICY see_extra_perms_groups_delete ON http_trigger FOR DELETE TO windmill_user
USING (exists(
SELECT key, value FROM jsonb_each_text(extra_perms)
WHERE SPLIT_PART(key, '/', 1) = 'g' AND key = ANY(regexp_split_to_array(current_setting('session.pgroups'), ',')::text[])
AND value::boolean));
-- Owner and member policies (other trigger tables have these, http_trigger was missing them)
CREATE POLICY see_own ON http_trigger FOR ALL TO windmill_user
USING (SPLIT_PART(http_trigger.path, '/', 1) = 'u' AND SPLIT_PART(http_trigger.path, '/', 2) = current_setting('session.user'));
CREATE POLICY see_member ON http_trigger FOR ALL TO windmill_user
USING (SPLIT_PART(http_trigger.path, '/', 1) = 'g' AND SPLIT_PART(http_trigger.path, '/', 2) = any(regexp_split_to_array(current_setting('session.groups'), ',')::text[]));
-- Update prevent_route_path_change to allow non-admins to change route_path on workspaced routes.
-- Previously all route_path changes were blocked for windmill_user; now only instance-wide routes are protected.
CREATE OR REPLACE FUNCTION prevent_route_path_change()
RETURNS TRIGGER AS $$
BEGIN
IF CURRENT_USER = 'windmill_user' AND NEW.route_path <> OLD.route_path AND NOT COALESCE(NEW.workspaced_route, false) THEN
RAISE EXCEPTION 'Modification of route_path is only allowed by admins for non-workspaced routes';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;

View File

@@ -13,7 +13,6 @@ use windmill_common::global_settings::HTTP_ROUTE_WORKSPACED_ROUTE;
use windmill_common::{
db::UserDB,
error::{Error, Result},
utils::require_admin,
worker::CLOUD_HOSTED,
DB,
};
@@ -139,6 +138,23 @@ fn check_no_duplicates(
Ok(())
}
/// Checks that non-admin users are only creating/editing workspaced HTTP triggers.
/// Returns the effective workspaced value (combining per-trigger flag and global setting).
/// Admins can create both workspaced and instance-wide routes.
async fn require_admin_for_instance_wide_route(
is_admin: bool,
workspaced_route: Option<bool>,
) -> Result<bool> {
let http_route_workspaced = *HTTP_ROUTE_WORKSPACED_ROUTE.read().await;
let effective_workspaced = workspaced_route.unwrap_or(false) || http_route_workspaced;
if !is_admin && !effective_workspaced {
return Err(Error::NotAuthorized(
"Non-admin users can only create workspaced HTTP triggers. Enable the workspace prefix for this route.".to_string(),
));
}
Ok(effective_workspaced)
}
pub async fn insert_new_trigger_into_db(
authed: &ApiAuthed,
_db: &DB,
@@ -147,11 +163,9 @@ pub async fn insert_new_trigger_into_db(
trigger: &TriggerData<HttpConfigRequest>,
route_path_key: &str,
) -> Result<()> {
require_admin(authed.is_admin, &authed.username)?;
let http_route_workspaced = *HTTP_ROUTE_WORKSPACED_ROUTE.read().await;
let effective_workspaced =
trigger.config.workspaced_route.unwrap_or(false) || http_route_workspaced;
require_admin_for_instance_wide_route(authed.is_admin, trigger.config.workspaced_route)
.await?;
let request_type = trigger.config.request_type;
let resolved_edited_by = trigger.base.resolve_edited_by(authed);
@@ -225,7 +239,7 @@ pub async fn create_many_http_triggers(
Path(w_id): Path<String>,
Json(new_http_triggers): Json<Vec<TriggerData<HttpConfigRequest>>>,
) -> Result<(StatusCode, String)> {
require_admin(authed.is_admin, &authed.username)?;
// Admin check for instance-wide routes is done per-trigger in insert_new_trigger_into_db
let handler = HttpTrigger;
@@ -451,7 +465,11 @@ impl TriggerCrud for HttpTrigger {
let resolved_edited_by = trigger.base.resolve_edited_by(authed);
let resolved_permissioned_as = trigger.base.resolve_permissioned_as(authed);
if authed.is_admin {
let http_route_workspaced = *HTTP_ROUTE_WORKSPACED_ROUTE.read().await;
let effective_workspaced =
trigger.config.workspaced_route.unwrap_or(false) || http_route_workspaced;
if authed.is_admin || effective_workspaced {
if trigger.config.route_path.is_empty() {
return Err(Error::BadRequest("route_path is required".to_string()));
};
@@ -464,10 +482,6 @@ impl TriggerCrud for HttpTrigger {
let route_path_key =
check_if_route_exist(db, &trigger.config, workspace_id, Some(path)).await?;
let http_route_workspaced = *HTTP_ROUTE_WORKSPACED_ROUTE.read().await;
let effective_workspaced =
trigger.config.workspaced_route.unwrap_or(false) || http_route_workspaced;
let request_type = trigger.config.request_type;
sqlx::query!(

View File

@@ -105,10 +105,12 @@
})
let userIsAdmin = $derived($userStore?.is_admin || $userStore?.is_super_admin)
let userCanEditConfig = $derived(userIsAdmin || isDraftOnly) // User can edit config if they are admin or if the trigger is a draft which will not be saved
let globalHttpWorkspacedRoute = $state(false)
let effectiveWorkspaced = $derived(workspaced_route || globalHttpWorkspacedRoute)
let userCanEditConfig = $derived(userIsAdmin || isDraftOnly || effectiveWorkspaced)
async function loadGlobalHttpWorkspacedRouteSetting() {
try {
const setting = await SettingService.getGlobal({ key: 'http_route_workspaced_route' })
@@ -121,7 +123,11 @@
loadGlobalHttpWorkspacedRouteSetting()
$effect.pre(() => {
if (globalHttpWorkspacedRoute && !workspaced_route) {
// Force workspaced route for non-admins on new triggers only.
// Existing non-workspaced triggers created by admins can still be edited by non-admins
// (with restricted fields), so we don't override their workspaced_route value.
const isNewTrigger = !initialTriggerPath
if ((globalHttpWorkspacedRoute || (!userIsAdmin && isNewTrigger)) && !workspaced_route) {
workspaced_route = true
dirtyRoutePath = true
}
@@ -135,9 +141,10 @@
<TestingBadge />
{/if}
{/snippet}
{#if !userCanEditConfig && isDraftOnly}
<Alert type="info" title="Admin only" collapsible size="xs">
Route endpoints can only be edited by workspace admins
{#if !userCanEditConfig}
<Alert type="info" title="Route config restricted" collapsible size="xs">
Route path, HTTP method, and workspace prefix can only be changed by workspace admins on
non-workspaced routes
</Alert>
<div class="my-2"></div>
{/if}
@@ -192,7 +199,7 @@
<Toggle
size="sm"
checked={workspaced_route}
disabled={!can_write || !userCanEditConfig || globalHttpWorkspacedRoute}
disabled={!can_write || !userIsAdmin || globalHttpWorkspacedRoute}
on:change={() => {
workspaced_route = !workspaced_route
dirtyRoutePath = true
@@ -200,7 +207,9 @@
options={{
right: globalHttpWorkspacedRoute
? 'Prefix with workspace (enforced by instance setting)'
: 'Prefix with workspace',
: !userIsAdmin
? 'Prefix with workspace (required for non-admin users)'
: 'Prefix with workspace',
rightTooltip:
'Prefixes the route with the workspace ID (e.g., {base_url}/api/r/{workspace_id}/{route}). Note: deploying the HTTP trigger to another workspace updates the route workspace prefix accordingly.',
rightDocumentationLink:

View File

@@ -136,7 +136,6 @@
let hasChanged = $derived(!deepEqual(getRouteConfig(), originalConfig ?? {}))
let scopes = $derived(['http_triggers:read:' + path])
const isAdmin = $derived($userStore?.is_admin || $userStore?.is_super_admin)
const routeConfig = $derived.by(getRouteConfig)
const captureConfig = $derived.by(untrack(() => isEditor) ? getCaptureConfig : () => ({}))
const saveDisabled = $derived(
@@ -526,7 +525,6 @@
checkInitialPathExistence={!edit}
namePlaceholder="route"
kind="http_trigger"
hideUser
disableEditing={!can_write}
/>
</Label>
@@ -954,7 +952,7 @@
{#if !drawerLoading}
<TriggerEditorToolbar
{trigger}
permissions={drawerLoading || !can_write ? 'none' : can_write && isAdmin ? 'create' : 'write'}
permissions={drawerLoading || !can_write ? 'none' : 'create'}
{saveDisabled}
{allowDraft}
{edit}

View File

@@ -16,7 +16,7 @@
import RouteEditor from './RouteEditor.svelte'
import { generateHttpTriggerFromOpenApi, type Source } from './utils'
import { isCloudHosted } from '$lib/cloud'
import { usedTriggerKinds, workspaceStore } from '$lib/stores'
import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores'
import FileInput from '../../common/fileInput/FileInput.svelte'
import { emptyStringTrimmed, sendUserToast } from '$lib/utils'
import FolderPicker from '../../FolderPicker.svelte'
@@ -134,10 +134,16 @@
}
}
let userIsAdmin = $derived($userStore?.is_admin || $userStore?.is_super_admin)
async function generateHttpTrigger() {
try {
isGeneratingHttpRoutes = true
httpTriggers = await generateHttpTriggerFromOpenApi(code, folderName)
// Force workspaced routes for non-admins or when the global setting is enabled
if (!userIsAdmin || globalHttpWorkspacedRoute) {
httpTriggers = httpTriggers.map((t) => ({ ...t, workspaced_route: true }))
}
if (httpTriggers.length === 0) {
sendUserToast('No paths defined in the OpenAPI spec. Cannot generate HTTP routes.', true)
}

View File

@@ -49,7 +49,7 @@
>
{#if !$userStore?.is_admin && !$userStore?.is_super_admin && selectedTrigger.isDraft}
<Alert title="Only workspace admins can create routes" type="info" size="xs" />
<Alert title="Non-admin users are limited to workspaced routes" type="info" size="xs" />
{/if}
</div>
{/snippet}

View File

@@ -39,7 +39,7 @@ export async function saveHttpRouteFromCfg(
routeCfg: Record<string, any>,
edit: boolean,
workspace: string,
isAdmin: boolean,
_isAdmin: boolean,
usedTriggerKinds: Writable<string[]>
): Promise<boolean> {
const requestBody: NewHttpTrigger = {
@@ -72,7 +72,7 @@ export async function saveHttpRouteFromCfg(
path: initialPath,
requestBody: {
...requestBody,
route_path: isAdmin || !edit ? routeCfg.route_path : undefined
route_path: routeCfg.route_path
}
})
sendUserToast(`Route ${routeCfg.path} updated`)

View File

@@ -294,38 +294,36 @@
tooltip="Every script and flow already has a canonical HTTP API endpoint/webhook attached to it, this is to create additional parametrizable ones."
documentationLink="https://www.windmill.dev/docs/core_concepts/http_routing"
>
{#if $userStore?.is_admin || $userStore?.is_super_admin}
<div class="flex flex-row gap-2">
<Button
unifiedSize="md"
variant="default"
startIcon={{ icon: Plus }}
on:click={() => {
routesGenerator?.openDrawer()
}}
>
From OpenAPI spec
</Button>
<Button
unifiedSize="md"
variant="default"
startIcon={{ icon: Plus }}
on:click={() => {
openAPISpecGenerator?.openDrawer()
}}
>
To OpenAPI spec
</Button>
<Button
unifiedSize="md"
variant="accent"
startIcon={{ icon: Plus }}
on:click={() => routeEditor?.openNew(false)}
>
New&nbsp;route
</Button>
</div>
{/if}
<div class="flex flex-row gap-2">
<Button
unifiedSize="md"
variant="default"
startIcon={{ icon: Plus }}
on:click={() => {
routesGenerator?.openDrawer()
}}
>
From OpenAPI spec
</Button>
<Button
unifiedSize="md"
variant="default"
startIcon={{ icon: Plus }}
on:click={() => {
openAPISpecGenerator?.openDrawer()
}}
>
To OpenAPI spec
</Button>
<Button
unifiedSize="md"
variant="accent"
startIcon={{ icon: Plus }}
on:click={() => routeEditor?.openNew(false)}
>
New&nbsp;route
</Button>
</div>
</PageHeader>
<div class="w-full h-full flex flex-col">
<div class="w-full pb-4 pt-6">
@@ -515,8 +513,7 @@
displayName: 'Delete',
type: 'delete',
icon: Trash,
disabled:
!canWrite || !($userStore?.is_admin || $userStore?.is_super_admin),
disabled: !canWrite,
action: async () => {
try {
await HttpTriggerService.deleteHttpTrigger({