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:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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!(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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 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 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({
|
||||
|
||||
Reference in New Issue
Block a user