rework ownership permissions

This commit is contained in:
Ruben Fiszel
2023-04-15 23:03:16 +02:00
parent f1282e3a92
commit 4c33daaba0
33 changed files with 593 additions and 484 deletions
+15
View File
@@ -2757,6 +2757,16 @@ paths:
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/ScriptPath"
requestBody:
description: archiveFlow
required: true
content:
application/json:
schema:
type: object
properties:
archived:
type: boolean
responses:
"200":
description: flow archived
@@ -5280,6 +5290,10 @@ components:
type: array
items:
type: string
folders_owners:
type: array
items:
type: string
usage:
$ref: "#/components/schemas/Usage"
required:
@@ -5291,6 +5305,7 @@ components:
- operator
- disabled
- folders
- folders_owners
Usage:
type: object
+1 -5
View File
@@ -442,7 +442,6 @@ async fn update_app(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
Extension(db): Extension<DB>,
Path((w_id, path)): Path<(String, StripPath)>,
Json(ns): Json<EditApp>,
) -> Result<String> {
@@ -459,10 +458,7 @@ async fn update_app(
if let Some(npath) = &ns.path {
if npath != path {
if !authed.is_admin {
require_owner_of_path(&w_id, &authed.username, &authed.groups, &path, &db)
.await?;
}
require_owner_of_path(&authed, path)?;
}
sqlb.set_str("path", npath);
}
+14 -6
View File
@@ -19,6 +19,7 @@ use axum::{
Json, Router,
};
use hyper::StatusCode;
use serde::Deserialize;
use sql_builder::prelude::*;
use sql_builder::SqlBuilder;
use sqlx::{Postgres, Transaction};
@@ -26,10 +27,11 @@ use windmill_audit::{audit_log, ActionKind};
use windmill_common::{
error::{self, to_anyhow, Error, JsonResult, Result},
flows::{Flow, ListFlowQuery, ListableFlow, NewFlow},
jobs::JobPayload,
schedule::Schedule,
utils::{
http_get_from_hub, list_elems_from_hub, not_found_if_none, paginate, Pagination, StripPath,
}, jobs::JobPayload,
},
};
use windmill_queue::{push, schedule::push_scheduled_job, QueueTransaction};
@@ -85,9 +87,8 @@ async fn list_flows(
.limit(per_page)
.clone();
if !lq.show_archived.unwrap_or(false) {
sqlb.and_where_eq("archived", false);
}
sqlb.and_where_eq("archived", lq.show_archived.unwrap_or(false));
if let Some(ps) = &lq.path_start {
sqlb.and_where_like_left("path", "?".bind(ps));
}
@@ -325,7 +326,7 @@ async fn update_flow(
check_schedule_conflict(tx.transaction_mut(), &w_id, &nf.path).await?;
if !authed.is_admin {
require_owner_of_path(&w_id, &authed.username, &authed.groups, &flow_path, &db).await?;
require_owner_of_path(&authed, flow_path)?;
}
let mut schedulables: Vec<Schedule> = sqlx::query_as!(
@@ -462,17 +463,24 @@ async fn exists_flow_by_path(
Ok(Json(exists))
}
#[derive(Deserialize)]
struct Archived {
archived: Option<bool>,
}
async fn archive_flow_by_path(
authed: Authed,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
Path((w_id, path)): Path<(String, StripPath)>,
Json(archived): Json<Archived>,
) -> Result<String> {
let path = path.to_path();
let mut tx = user_db.begin(&authed).await?;
sqlx::query!(
"UPDATE flow SET archived = true WHERE path = $1 AND workspace_id = $2",
"UPDATE flow SET archived = $1 WHERE path = $2 AND workspace_id = $3",
archived.archived.unwrap_or(true),
path,
&w_id
)
+25 -49
View File
@@ -22,13 +22,13 @@ use lazy_static::lazy_static;
use regex::Regex;
use windmill_audit::{audit_log, ActionKind};
use windmill_common::{
error::{self, to_anyhow, Error, JsonResult, Result},
error::{self, to_anyhow, JsonResult, Result},
users::username_to_permissioned_as,
utils::{not_found_if_none, paginate, Pagination},
};
use serde::{Deserialize, Serialize};
use sqlx::{query_scalar, FromRow, Postgres, Transaction};
use sqlx::{FromRow, Postgres, Transaction};
pub fn workspaced_service() -> Router {
Router::new()
@@ -41,7 +41,7 @@ pub fn workspaced_service() -> Router {
.route("/delete/:name", delete(delete_folder))
.route("/addowner/:name", post(add_owner))
.route("/removeowner/:name", post(remove_owner))
.route("/is_owner/*path", get(is_owner))
.route("/is_owner/*path", get(is_owner_api))
}
#[derive(FromRow, Serialize, Deserialize, Clone)]
@@ -219,47 +219,29 @@ async fn create_folder(
Ok(format!("Created folder {}", ng.name))
}
pub async fn is_owner(
Authed { username, is_admin, groups, .. }: Authed,
Extension(db): Extension<DB>,
Path((w_id, name)): Path<(String, String)>,
pub async fn is_owner_api(
authed: Authed,
Path((_w_id, name)): Path<(String, String)>,
) -> JsonResult<bool> {
if is_admin {
Ok(Json(true))
Ok(Json(is_owner(&authed, &name)))
}
pub fn is_owner(Authed { is_admin, folders, .. }: &Authed, name: &str) -> bool {
if *is_admin {
true
} else {
Ok(Json(
require_is_owner(&name, &username, &groups, &w_id, &db)
.await
.is_ok(),
))
folders.into_iter().any(|x| x.0 == name && x.2)
}
}
pub async fn require_is_owner(
folder_name: &str,
username: &str,
groups: &Vec<String>,
w_id: &str,
db: &DB,
) -> Result<()> {
let is_owner = query_scalar!(
"SELECT EXISTS(SELECT 1 FROM folder WHERE CONCAT('u/', $1::text) = ANY(owners) AND name = $2 AND workspace_id = $4) OR exists(
SELECT 1 FROM folder, unnest(folder.owners) as o
WHERE o = ANY($3::text[]) AND folder.name = $2 AND folder.workspace_id = $4)",
username,
folder_name,
groups,
w_id,
).fetch_one(db)
.await?
.unwrap_or(false);
if !is_owner {
Err(Error::BadRequest(format!(
"{} is not an owner of {} and hence is not authorized to perform this operation",
username, folder_name
)))
} else {
pub fn require_is_owner(authed: &Authed, name: &str) -> Result<()> {
if is_owner(authed, name) {
Ok(())
} else {
Err(windmill_common::error::Error::NotAuthorized(format!(
"You are not owner of the folder {}",
name
)))
}
}
@@ -491,7 +473,6 @@ async fn delete_folder(
async fn add_owner(
authed: Authed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
Path((w_id, name)): Path<(String, String)>,
@@ -500,9 +481,7 @@ async fn add_owner(
let mut tx = user_db.begin(&authed).await?;
not_found_if_none(get_folderopt(&mut tx, &w_id, &name).await?, "Folder", &name)?;
if !authed.is_admin {
require_is_owner(&name, &authed.username, &authed.groups, &w_id, &db).await?;
}
require_is_owner(&authed, &name)?;
sqlx::query!(
"UPDATE folder SET owners = array_append(owners, $1) WHERE name = $2 AND workspace_id = $3 AND NOT $1 = ANY(owners) RETURNING name",
@@ -538,14 +517,14 @@ pub async fn get_folders_for_user(
username: &str,
groups: &[String],
db: &DB,
) -> Result<Vec<(String, bool)>> {
) -> Result<Vec<(String, bool, bool)>> {
let mut perms = groups
.into_iter()
.map(|x| format!("g/{}", x))
.collect::<Vec<_>>();
perms.insert(0, format!("u/{}", username));
let folders = sqlx::query!(
"SELECT name, (EXISTS (SELECT 1 FROM (SELECT key, value FROM jsonb_each_text(extra_perms) WHERE key = ANY($1)) t WHERE value::boolean IS true)) as write FROM folder
"SELECT name, (EXISTS (SELECT 1 FROM (SELECT key, value FROM jsonb_each_text(extra_perms) WHERE key = ANY($1)) t WHERE value::boolean IS true)) as write, $1 && owners::text[] as owner FROM folder
WHERE extra_perms ?| $1 AND workspace_id = $2",
&perms[..],
w_id,
@@ -553,7 +532,7 @@ pub async fn get_folders_for_user(
.fetch_all(db)
.await?
.into_iter()
.map(|x| (x.name, x.write.unwrap_or(false)))
.map(|x| (x.name, x.write.unwrap_or(false), x.owner.unwrap_or(false)))
.collect();
Ok(folders)
@@ -561,7 +540,6 @@ pub async fn get_folders_for_user(
async fn remove_owner(
authed: Authed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
Path((w_id, name)): Path<(String, String)>,
@@ -570,9 +548,7 @@ async fn remove_owner(
let mut tx = user_db.begin(&authed).await?;
not_found_if_none(get_folderopt(&mut tx, &w_id, &name).await?, "Folder", &name)?;
if !authed.is_admin {
require_is_owner(&name, &authed.username, &authed.groups, &w_id, &db).await?;
}
require_is_owner(&authed, &name)?;
sqlx::query!(
"UPDATE folder SET owners = array_remove(owners, $1) WHERE name = $2 AND workspace_id = $3 RETURNING name",
+31 -12
View File
@@ -43,25 +43,29 @@ async fn add_granular_acl(
Json(GranularAcl { owner, write }): Json<GranularAcl>,
) -> Result<String> {
let path = path.to_path();
let (kind, path) = path
.split_once('/')
.ok_or_else(|| Error::BadRequest("Invalid path or kind".to_string()))?;
let mut tx = user_db.begin(&authed).await?;
if !authed.is_admin {
if kind == "folder" {
crate::folders::require_is_owner(&path, &authed.username, &authed.groups, &w_id, &db)
.await?;
} else if kind == "group_" {
} else {
require_owner_of_path(&w_id, &authed.username, &authed.groups, &path, &db).await?;
}
}
let identifier = if kind == "group_" || kind == "folder" {
"name"
} else {
"path"
};
if !authed.is_admin {
if kind == "folder" {
crate::folders::require_is_owner(&authed, path)?;
} else if kind == "group_" {
crate::groups::require_is_owner(path, &authed.username, &authed.groups, &w_id, &db)
.await?;
} else {
require_owner_of_path(&authed, path)?;
}
}
let obj_o = sqlx::query_scalar::<_, serde_json::Value>(&format!(
"UPDATE {kind} SET extra_perms = jsonb_set(extra_perms, '{{\"{owner}\"}}', to_jsonb($1), \
true) WHERE {identifier} = $2 AND workspace_id = $3 RETURNING extra_perms"
@@ -86,12 +90,22 @@ async fn remove_granular_acl(
Json(GranularAcl { owner, write: _ }): Json<GranularAcl>,
) -> Result<String> {
let path = path.to_path();
if !authed.is_admin {
require_owner_of_path(&w_id, &authed.username, &authed.groups, &path, &db).await?;
}
let (kind, path) = path
.split_once('/')
.ok_or_else(|| Error::BadRequest("Invalid path or kind".to_string()))?;
if !authed.is_admin {
if kind == "folder" {
crate::folders::require_is_owner(&authed, path)?;
} else if kind == "group_" {
crate::groups::require_is_owner(path, &authed.username, &authed.groups, &w_id, &db)
.await?;
} else {
require_owner_of_path(&authed, path)?;
}
}
let mut tx = user_db.begin(&authed).await?;
let identifier = if kind == "group_" || kind == "folder" {
@@ -99,6 +113,11 @@ async fn remove_granular_acl(
} else {
"path"
};
if identifier == "path" {
require_owner_of_path(&authed, path)?;
}
let obj_o = sqlx::query_scalar::<_, serde_json::Value>(&format!(
"UPDATE {kind} SET extra_perms = extra_perms - $1 WHERE {identifier} = $2 AND \
workspace_id = $3 RETURNING extra_perms"
+6 -11
View File
@@ -626,7 +626,7 @@ async fn list_jobs(
pub async fn resume_suspended_flow_as_owner(
authed: Authed,
Extension(db): Extension<DB>,
Path((w_id, flow_id)): Path<(String, Uuid)>,
Path((_w_id, flow_id)): Path<(String, Uuid)>,
QueryOrBody(value): QueryOrBody<serde_json::Value>,
) -> error::Result<StatusCode> {
let value = value.unwrap_or(serde_json::Value::Null);
@@ -634,16 +634,11 @@ pub async fn resume_suspended_flow_as_owner(
let (flow, job_id) = get_suspended_flow_info(flow_id, &mut tx).await?;
if !authed.is_admin {
require_owner_of_path(
&w_id,
&authed.username,
&authed.groups,
&flow.script_path.clone().unwrap_or_else(|| String::new()),
&db,
)
.await?;
}
require_owner_of_path(
&authed,
&flow.script_path.clone().unwrap_or_else(|| String::new()),
)?;
insert_resume_job(0, job_id, &flow, value, Some(authed.username), &mut tx).await?;
resume_immediately_if_relevant(flow, job_id, &mut tx).await?;
+2 -3
View File
@@ -386,9 +386,8 @@ async fn update_resource(
if npath != path {
check_path_conflict(&mut tx, &w_id, &npath).await?;
if !authed.is_admin {
require_owner_of_path(&w_id, &authed.username, &authed.groups, &path, &db).await?;
}
require_owner_of_path(&authed, path)?;
sqlx::query!(
"UPDATE variable SET path = $1 WHERE path = $2 AND workspace_id = $3",
npath,
+3 -4
View File
@@ -273,10 +273,7 @@ async fn create_script(
let ps = get_script_by_hash_internal(tx.transaction_mut(), &w_id, p_hash).await?;
if ps.path != ns.path {
if !authed.is_admin {
require_owner_of_path(&w_id, &authed.username, &authed.groups, &ps.path, &db)
.await?;
}
require_owner_of_path(&authed, &ps.path)?;
}
let ph = {
@@ -637,6 +634,8 @@ async fn archive_script_by_path(
let path = path.to_path();
let mut tx = user_db.begin(&authed).await?;
require_owner_of_path(&authed, path)?;
let hash: i64 = sqlx::query_scalar!(
"UPDATE script SET archived = true WHERE path = $1 AND workspace_id = $2 RETURNING hash",
path,
+2 -3
View File
@@ -380,9 +380,8 @@ async fn update_variable(
if let Some(npath) = ns.path {
if npath != path {
check_path_conflict(&mut tx, &w_id, &npath).await?;
if !authed.is_admin {
require_owner_of_path(&w_id, &authed.username, &authed.groups, &path, &db).await?;
}
require_owner_of_path(&authed, path)?;
let mut v = sqlx::query_scalar!(
"SELECT value FROM resource WHERE path = $1 AND workspace_id = $2",
path,
@@ -461,7 +461,7 @@
>
{/if}
{/if}
<div slot="actions">
<div slot="actions" class="flex gap-1">
{#if step > 1 && !no_back}
<Button variant="border" on:click={back}>Back</Button>
{/if}
+17 -15
View File
@@ -311,21 +311,23 @@
{/if}
{/key}
</div>
<Button
variant="border"
color="dark"
size="xs"
btnClasses="mt-1"
on:click={() => {
if (value == undefined || !Array.isArray(value)) {
value = []
}
value = value.concat('')
}}
>
<Icon data={faPlus} class="mr-2" />
Add item
</Button>
<div class="flex mt-2">
<Button
variant="border"
color="dark"
size="xs"
btnClasses="mt-1"
on:click={() => {
if (value == undefined || !Array.isArray(value)) {
value = []
}
value = value.concat('')
}}
>
<Icon data={faPlus} class="mr-2" />
Add item
</Button>
</div>
</div>
{:else if inputCat == 'resource-object'}
<ObjectResourceInput {format} bind:value />
+12 -4
View File
@@ -11,13 +11,21 @@
type Side = 'top' | 'bottom'
type Placement = `${Side}-${Alignment}`
export let dropdownItems: DropdownItem[]
export let dropdownItems: DropdownItem[] | (() => DropdownItem[]) = []
export let name: string | undefined = undefined
export let placement: Placement = 'bottom-start'
export let btnClasses = ''
$: buttonClass = twMerge('!border-0 bg-transparent !p-[6px]', btnClasses)
const dispatch = createEventDispatcher()
function computeDropdowns(): DropdownItem[] {
if (typeof dropdownItems === 'function') {
return dropdownItems()
} else {
return dropdownItems
}
}
</script>
<Menu {placement} let:close>
@@ -37,7 +45,7 @@
{/if}
</Button>
{#if dropdownItems}
{#each dropdownItems as item, i}
{#each computeDropdowns() as item, i}
{#if item.action}
<button
on:click|preventDefault|stopPropagation={(event) => {
@@ -91,8 +99,8 @@
{:else}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<span
class:bg-gray-50={item.disabled}
class="block text-left px-4 py-2 text-sm text-gray-700 cursor-auto"
class:bg-gray-200={item.disabled}
class="block font-semibold text-left px-4 py-2 text-sm text-gray-700 cursor-auto"
role="menuitem"
tabindex="-1"
id="user-menu-item-{name}-{i}}"
@@ -158,8 +158,8 @@
timeout && clearTimeout(timeout)
})
async function loadOwner(path: string) {
is_owner = await isOwner(path, $userStore!, workspaceId ?? $workspaceStore!)
function loadOwner(path: string) {
is_owner = isOwner(path, $userStore!, workspaceId ?? $workspaceStore!)
}
let selected: 'graph' | 'sequence' = 'graph'
@@ -220,21 +220,23 @@
</div>
{/each}
</div>
<Button
variant="border"
color="blue"
size="sm"
btnClasses="mt-1"
on:click={() => {
if (value == undefined || !Array.isArray(value)) {
value = []
}
value = value.concat('')
}}
>
<Icon data={faPlus} class="mr-2" />
Add item
</Button>
<div class="flex">
<Button
variant="border"
color="blue"
size="sm"
btnClasses="mt-1"
on:click={() => {
if (value == undefined || !Array.isArray(value)) {
value = []
}
value = value.concat('')
}}
>
<Icon data={faPlus} class="mr-2" />
Add item
</Button>
</div>
<span class="ml-2">
{(value ?? []).length} item{(value ?? []).length > 1 ? 's' : ''}
</span>
@@ -27,12 +27,12 @@
kind = kind_l
initialPath = initialPath_l
summary = summary_l
await loadOwner()
loadOwner()
drawer.openDrawer()
}
async function loadOwner() {
own = await isOwner(initialPath, $userStore!, $workspaceStore!)
function loadOwner() {
own = isOwner(initialPath, $userStore!, $workspaceStore!)
}
async function updatePath() {
+1
View File
@@ -337,6 +337,7 @@
btnClasses="!p-1.5"
variant="border"
size="xs"
{disabled}
on:click={newFolder.openDrawer}
>
<Icon scale={0.8} data={faPlus} /></Button
@@ -1,7 +1,7 @@
<script lang="ts">
import type { Schema } from '$lib/common'
import { ResourceService, type Resource } from '$lib/gen'
import { canWrite, emptyString, sendUserToast } from '$lib/utils'
import { canWrite, emptyString, isOwner, sendUserToast } from '$lib/utils'
import { createEventDispatcher } from 'svelte'
import { Alert, Button, Drawer, Skeleton } from './common'
import Path from './Path.svelte'
@@ -141,7 +141,7 @@
</div>
{/if}
<Path
disabled={!can_write}
disabled={initialPath != '' && !isOwner(initialPath, $userStore, $workspaceStore)}
bind:path
{initialPath}
namePlaceholder="resource"
+74 -64
View File
@@ -18,6 +18,7 @@
import SharedBadge from './SharedBadge.svelte'
import Toggle from './Toggle.svelte'
import Tooltip from './Tooltip.svelte'
import CollapseLink from './CollapseLink.svelte'
export let runnable:
| {
@@ -136,62 +137,67 @@
<div class="text-xs text-gray-600">No schema</div>
{/if}
{#if schedulable}
<div class="flex gap-2 items-start flex-wrap justify-between mt-2 md:mt-6 mb-6">
<div class="flex flex-col">
<div>
<Button
color="light"
size="sm"
endIcon={{ icon: viewOptions ? faChevronUp : faChevronDown }}
on:click={() => (viewOptions = !viewOptions)}
>
Schedule to run later
</Button>
</div>
{#if viewOptions}
<div transition:slide|local class="mt-6">
<div class="border rounded-md p-3 pt-4">
<div class="flex flex-row items-end">
<div class="w-max md:w-2/3 mt-2 mb-1">
<label for="run-time" />
<input
class="inline-block"
type="datetime-local"
id="run-time"
name="run-scheduled-time"
bind:value={scheduledForStr}
min={getToday().toISOString().slice(0, 16)}
/>
<div class="mt-10" />
<CollapseLink text="Advanced">
<div class="flex flex-col gap-4 mt-2 border p-2">
<div class="flex flex-col gap-2">
<div class="flex">
<Button
color="light"
size="sm"
endIcon={{ icon: viewOptions ? faChevronUp : faChevronDown }}
on:click={() => (viewOptions = !viewOptions)}
>
Schedule to run later
</Button>
</div>
{#if viewOptions}
<div transition:slide|local class="mt-6">
<div class="border rounded-md p-3 pt-4">
<div class="flex flex-row items-end">
<div class="w-max md:w-2/3 mt-2 mb-1">
<label for="run-time" />
<input
class="inline-block"
type="datetime-local"
id="run-time"
name="run-scheduled-time"
bind:value={scheduledForStr}
min={getToday().toISOString().slice(0, 16)}
/>
</div>
<Button
variant="border"
color="blue"
size="sm"
btnClasses="mx-2 mb-1"
on:click={() => {
scheduledForStr = undefined
}}
>
Clear
</Button>
</div>
<Button
variant="border"
color="blue"
size="sm"
btnClasses="mx-2 mb-1"
on:click={() => {
scheduledForStr = undefined
}}
>
Clear
</Button>
</div>
</div>
{/if}
</div>
{#if runnable?.path?.startsWith(`u/${$userStore?.username}`) != true && (runnable?.path?.split('/')?.length ?? 0) > 2}
<div class="flex items-center gap-1">
<Toggle
options={{
right: `make run invisible to others`
}}
bind:checked={invisible_to_owner}
/>
<Tooltip
>By default, runs are visible to the owner(s) of the script or flow being triggered</Tooltip
>
</div>
{/if}
</div>
{#if runnable?.path?.startsWith(`u/${$userStore?.username}`) != true && (runnable?.path?.split('/')?.length ?? 0) > 2}
<div class="flex items-center gap-1">
<Toggle
options={{
right: `make run invisible to others`
}}
bind:checked={invisible_to_owner}
/>
<Tooltip
>By default, runs are visible to the owner(s) of the script or flow being triggered</Tooltip
>
</div>
{/if}
</CollapseLink>
<div class="flex gap-2 items-start flex-wrap justify-between mt-2 md:mt-6 mb-6">
<div class="flex-row-reverse flex grow">
<Button
{loading}
@@ -214,20 +220,24 @@
{/if}
{#if viewCliRun}
<div class="my-10" />
<Button
color="light"
size="xs"
endIcon={{ icon: viewCliOptions ? faChevronUp : faChevronDown }}
on:click={() => (viewCliOptions = !viewCliOptions)}
>
Run it from the CLI
</Button>
{#if viewCliOptions}
<div transition:slide|local class="mt-2 px-4 pt-2">
<InlineCodeCopy content={cliCommand} />
<CliHelpBox />
<div>
<div class="my-20" />
<div class="flex">
<Button
color="light"
size="xs"
endIcon={{ icon: viewCliOptions ? faChevronUp : faChevronDown }}
on:click={() => (viewCliOptions = !viewCliOptions)}
>
Run it from the CLI
</Button>
</div>
{/if}
{#if viewCliOptions}
<div transition:slide|local class="mt-2 px-4 pt-2">
<InlineCodeCopy content={cliCommand} />
<CliHelpBox />
</div>
{/if}
</div>
{/if}
</div>
@@ -38,13 +38,14 @@
loadAcls()
loadGroups()
loadUsernames()
await loadOwner()
loadOwner()
drawer.openDrawer()
}
async function loadOwner() {
own = await isOwner(path, $userStore!, $workspaceStore!)
own = isOwner(path, $userStore!, $workspaceStore!)
}
async function loadAcls() {
acls = Object.entries(
await GranularAclService.getGranularAcls({ workspace: $workspaceStore!, path, kind })
+33 -29
View File
@@ -11,39 +11,43 @@
let reason = ''
$: {
let username = $userStore?.username ?? ''
let pgroups = $userStore?.pgroups ?? []
let pusername = `u/${username}`
let extraPermsKeys = Object.keys(extraPerms)
if (pusername in extraPermsKeys) {
if (extraPerms[pusername]) {
kind = 'write'
} else {
kind = 'read'
}
reason = 'This item was shared to you personally'
if ($userStore?.is_admin || $userStore?.is_super_admin) {
kind = undefined
} else {
let writeGroup = pgroups.find((x) => extraPermsKeys.includes(x) && extraPerms[x])
if (writeGroup) {
kind = 'write'
reason = `This item was write shared to the group ${writeGroup} which you are a member of`
} else {
let readGroup = pgroups.find((x) => extraPermsKeys.includes(x))
if (readGroup) {
kind = 'read'
reason = `This item was read-only shared to the group ${readGroup} which you are a member of`
let username = $userStore?.username ?? ''
let pgroups = $userStore?.pgroups ?? []
let pusername = `u/${username}`
let extraPermsKeys = Object.keys(extraPerms)
if (pusername in extraPermsKeys) {
if (extraPerms[pusername]) {
kind = 'write'
} else {
kind = undefined
kind = 'read'
}
reason = 'This item was shared to you personally'
} else {
let writeGroup = pgroups.find((x) => extraPermsKeys.includes(x) && extraPerms[x])
if (writeGroup) {
kind = 'write'
reason = `This item was write shared to the group ${writeGroup} which you are a member of`
} else {
let readGroup = pgroups.find((x) => extraPermsKeys.includes(x))
if (readGroup) {
kind = 'read'
reason = `This item was read-only shared to the group ${readGroup} which you are a member of`
} else {
kind = undefined
}
}
}
}
if (kind == 'read' && canWrite) {
kind = undefined
}
if (kind == undefined && !canWrite) {
kind = 'read'
reason = ''
if (kind == 'read' && canWrite) {
kind = undefined
}
if (kind == undefined && !canWrite) {
kind = 'read'
reason = ''
}
}
}
</script>
@@ -1,5 +1,5 @@
<script lang="ts">
import { canWrite, sendUserToast } from '$lib/utils'
import { canWrite, isOwner, sendUserToast } from '$lib/utils'
import { VariableService } from '$lib/gen'
import Path from './Path.svelte'
import { createEventDispatcher } from 'svelte'
@@ -145,7 +145,7 @@
{/if}
<span class="font-semibold text-gray-700">Path</span>
<Path
disabled={!can_write}
disabled={initialPath != '' && !isOwner(initialPath, $userStore, $workspaceStore)}
bind:error={pathError}
bind:path
{initialPath}
@@ -16,7 +16,7 @@
<span class="font-semibold truncate text-gray-800">{title ?? ''}</span>
</div>
{#if $$slots.actions}
<div class="flex gap-1 items-center justify-end">
<div class="flex gap-2 items-center justify-end">
<slot name="actions" />
</div>
{/if}
@@ -7,7 +7,7 @@
import type ShareModal from '$lib/components/ShareModal.svelte'
import { FlowService, type Flow } from '$lib/gen'
import { userStore, workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/utils'
import { isOwner, sendUserToast } from '$lib/utils'
import {
faArchive,
faCalendarAlt,
@@ -36,9 +36,13 @@
const dispatch = createEventDispatcher()
async function archiveFlow(path: string): Promise<void> {
async function archiveFlow(path: string, archived: boolean): Promise<void> {
try {
await FlowService.archiveFlowByPath({ workspace: $workspaceStore!, path })
await FlowService.archiveFlowByPath({
workspace: $workspaceStore!,
path,
requestBody: { archived }
})
dispatch('change')
sendUserToast(`Archived flow ${path}`)
} catch (err) {
@@ -129,76 +133,79 @@
<Dropdown
placement="bottom-end"
dropdownItems={[
{
displayName: 'View flow',
icon: faEye,
href: `/flows/get/${path}?workspace=${$workspaceStore}`
},
{
displayName: 'Edit',
icon: faEdit,
href: `/flows/edit/${path}?nodraft=true`,
disabled: !canWrite || archived
},
{
displayName: 'Use as template/Fork',
icon: faCodeFork,
href: `/flows/add?template=${path}`
},
{
displayName: 'View runs',
icon: faList,
href: `/runs/${path}`
},
{
displayName: 'Move/Rename',
icon: faFileExport,
action: () => {
moveDrawer.openDrawer(path, summary, 'flow')
dropdownItems={() => {
let owner = isOwner(path, $userStore, $workspaceStore)
return [
{
displayName: 'View flow',
icon: faEye,
href: `/flows/get/${path}?workspace=${$workspaceStore}`
},
disabled: !canWrite || archived
},
{
displayName: 'Schedule',
icon: faCalendarAlt,
action: () => {
scheduleEditor.openNew(true, path)
{
displayName: 'Edit',
icon: faEdit,
href: `/flows/edit/${path}?nodraft=true`,
disabled: !canWrite || archived
},
disabled: archived
},
{
displayName: canWrite ? 'Share' : 'See Permissions',
icon: faShare,
action: () => {
shareModal.openDrawer && shareModal.openDrawer(path, 'flow')
}
},
{
displayName: 'Archive',
icon: faArchive,
action: () => {
path ? archiveFlow(path) : null
{
displayName: 'Use as template/Fork',
icon: faCodeFork,
href: `/flows/add?template=${path}`
},
type: 'delete',
disabled: !canWrite || archived
},
{
displayName: 'Delete',
icon: faTrashAlt,
action: (event) => {
if (event?.shiftKey) {
deleteFlow(path)
} else {
deleteConfirmedCallback = () => {
deleteFlow(path)
}
{
displayName: 'View runs',
icon: faList,
href: `/runs/${path}`
},
{
displayName: 'Move/Rename',
icon: faFileExport,
action: () => {
moveDrawer.openDrawer(path, summary, 'flow')
},
disabled: !owner || archived
},
{
displayName: 'Schedule',
icon: faCalendarAlt,
action: () => {
scheduleEditor.openNew(true, path)
},
disabled: archived
},
{
displayName: owner ? 'Share' : 'See Permissions',
icon: faShare,
action: () => {
shareModal.openDrawer && shareModal.openDrawer(path, 'flow')
}
},
type: 'delete',
disabled: !canWrite
}
]}
{
displayName: archived ? 'Unarchive' : 'Archive',
icon: faArchive,
action: () => {
path && archiveFlow(path, !archived)
},
type: 'delete',
disabled: !owner
},
{
displayName: 'Delete',
icon: faTrashAlt,
action: (event) => {
if (event?.shiftKey) {
deleteFlow(path)
} else {
deleteConfirmedCallback = () => {
deleteFlow(path)
}
}
},
type: 'delete',
disabled: !owner
}
]
}}
/>
</svelte:fragment>
</Row>
@@ -8,7 +8,7 @@
import { ScriptService, type Script } from '$lib/gen'
import { userStore, workspaceStore } from '$lib/stores'
import { capitalize, sendUserToast } from '$lib/utils'
import { capitalize, isOwner, sendUserToast } from '$lib/utils'
import {
faArchive,
faCalendarAlt,
@@ -55,6 +55,20 @@
sendUserToast(`Archived script ${path}`)
}
async function unarchiveScript(path: string): Promise<void> {
const r = await ScriptService.getScriptByPath({ workspace: $workspaceStore!, path })
await ScriptService.createScript({
workspace: $workspaceStore!,
requestBody: {
...r,
parent_hash: r.hash,
lock: r.lock?.split('\n')
}
})
dispatch('change')
sendUserToast(`Unarchived script ${path}`)
}
async function deleteScript(path: string): Promise<void> {
await ScriptService.deleteScriptByPath({ workspace: $workspaceStore!, path })
dispatch('change')
@@ -145,88 +159,91 @@
</span>
<Dropdown
placement="bottom-end"
dropdownItems={[
{
displayName: 'View script',
icon: faEye,
href: `/scripts/get/${hash}?workspace=${$workspaceStore}`
},
dropdownItems={() => {
let owner = isOwner(path, $userStore, $workspaceStore)
return [
{
displayName: 'View script',
icon: faEye,
href: `/scripts/get/${hash}?workspace=${$workspaceStore}`
},
{
displayName: 'Edit',
icon: faEdit,
href: `/scripts/edit/${hash}`,
disabled: !canWrite || archived
},
{
displayName: 'Edit code',
icon: faEdit,
href: `/scripts/edit/${hash}`,
disabled: !canWrite || archived
},
{
displayName: 'Use as template',
icon: faCodeFork,
href: `/scripts/add?template=${path}`
},
{
displayName: 'Move/Rename',
icon: faFileExport,
action: () => {
moveDrawer.openDrawer(path, summary, 'script')
{
displayName: 'Edit',
icon: faEdit,
href: `/scripts/edit/${hash}`,
disabled: !canWrite || archived
},
disabled: !canWrite || archived
},
{
displayName: 'View runs',
icon: faList,
href: `/runs/${path}`
},
{
displayName: 'Schedule',
icon: faCalendarAlt,
action: () => {
scheduleEditor.openNew(false, path)
{
displayName: 'Edit code',
icon: faEdit,
href: `/scripts/edit/${hash}`,
disabled: !canWrite || archived
},
disabled: archived
},
{
displayName: canWrite ? 'Share' : 'See Permissions',
icon: faShare,
action: () => {
shareModal.openDrawer && shareModal.openDrawer(path, 'script')
{
displayName: 'Use as template',
icon: faCodeFork,
href: `/scripts/add?template=${path}`
},
disabled: archived
},
{
displayName: 'Archive',
icon: faArchive,
action: () => {
path ? archiveScript(path) : null
{
displayName: 'Move/Rename',
icon: faFileExport,
action: () => {
moveDrawer.openDrawer(path, summary, 'script')
},
disabled: !owner || archived
},
type: 'delete',
disabled: !canWrite || archived
},
...($userStore?.is_admin || $userStore?.is_super_admin
? []
: [
{
displayName: 'Delete',
icon: faTrashAlt,
action: (event) => {
if (event?.shiftKey) {
deleteScript(path)
} else {
deleteConfirmedCallback = () => {
{
displayName: 'View runs',
icon: faList,
href: `/runs/${path}`
},
{
displayName: 'Schedule',
icon: faCalendarAlt,
action: () => {
scheduleEditor.openNew(false, path)
},
disabled: archived
},
{
displayName: owner ? 'Share' : 'See Permissions',
icon: faShare,
action: () => {
shareModal.openDrawer && shareModal.openDrawer(path, 'script')
},
disabled: archived
},
{
displayName: archived ? 'Unarchive' : 'Archive',
icon: faArchive,
action: () => {
archived ? path && unarchiveScript(path) : path && archiveScript(path)
},
type: 'delete',
disabled: !owner
},
...($userStore?.is_admin || $userStore?.is_super_admin
? [
{
displayName: 'Delete',
icon: faTrashAlt,
action: (event) => {
if (event?.shiftKey) {
deleteScript(path)
} else {
deleteConfirmedCallback = () => {
deleteScript(path)
}
}
}
},
type: dlt,
disabled: !canWrite
}
])
]}
},
type: dlt,
disabled: !canWrite
}
]
: [])
]
}}
/>
</svelte:fragment>
</Row>
@@ -302,7 +302,10 @@
</div>
<div class="relative">
<ListFilters bind:selectedFilter={ownerFilter} filters={owners} />
{#if !loading && filteredItems?.length}
{#if filteredItems?.length == 0}
<div class="mt-10" />
{/if}
{#if !loading}
<div class="absolute -bottom-2 right-0 bg-white/90">
<Toggle size="xs" bind:checked={archived} options={{ right: 'Show archived' }} /></div
>
+1
View File
@@ -15,6 +15,7 @@ export interface UserExt {
groups: string[]
pgroups: string[]
folders: string[]
folders_owners: string[]
}
let persistedWorkspace = browser && localStorage.getItem('workspace')
+23 -17
View File
@@ -1,14 +1,6 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { goto } from '$app/navigation'
import {
AppService,
type Flow,
FlowService,
Script,
ScriptService,
type User,
UserService
} from '$lib/gen'
import { AppService, type Flow, FlowService, Script, ScriptService, type User } from '$lib/gen'
import { toast } from '@zerodevx/svelte-toast'
import type { Schema, SupportedLanguage } from './common'
import { hubScripts, type UserExt, workspaceStore } from './stores'
@@ -189,16 +181,27 @@ export function removeItemAll<T>(arr: T[], value: T) {
return arr
}
export async function isOwner(path: string, user: UserExt, workspace: string): Promise<boolean> {
if (user.is_admin && (workspace == 'starter' || workspace == 'admin') && user.is_super_admin) {
return true
} else if (workspace == 'starter' || workspace == 'admin') {
export function isOwner(
path: string,
user: UserExt | undefined,
workspace: string | undefined
): boolean {
if (!user || !workspace) {
return false
}
if (user.is_super_admin) {
return true
}
if (workspace == 'admin') {
return false
} else if (user.is_admin) {
return true
} else if (path.startsWith('u/' + user.username + '/')) {
return true
} else if (path.startsWith('f/')) {
return user.folders_owners.some((x) => path.startsWith('f/' + x + '/'))
} else {
return await UserService.isOwnerOfPath({
path: path,
workspace: workspace
})
return false
}
}
@@ -227,6 +230,9 @@ export function canWrite(
extra_perms: Record<string, boolean>,
user?: UserExt
): boolean {
if (user?.is_admin || user?.is_super_admin) {
return true
}
let keys = Object.keys(extra_perms)
if (!user) {
return false
@@ -26,7 +26,7 @@
import { goto } from '$app/navigation'
import CenteredPage from '$lib/components/CenteredPage.svelte'
import { Badge, Button, Skeleton } from '$lib/components/common'
import { Alert, Badge, Button, Skeleton } from '$lib/components/common'
import CronInput from '$lib/components/CronInput.svelte'
import FlowViewer from '$lib/components/FlowViewer.svelte'
import JobArgs from '$lib/components/JobArgs.svelte'
@@ -75,7 +75,11 @@
}
async function archiveFlow(): Promise<void> {
await FlowService.archiveFlowByPath({ workspace: $workspaceStore!, path })
await FlowService.archiveFlowByPath({
workspace: $workspaceStore!,
path,
requestBody: { archived: !flow?.archived }
})
loadFlow()
}
@@ -235,10 +239,8 @@
>
{#if flow.archived}
<div class="bg-red-100 border-l-4 border-red-500 text-orange-700 p-4" role="alert">
<p class="font-bold">Archived</p>
<p>This flow was archived</p>
</div>
<div class="mt-2" />
<Alert type="error" title="Archived">This flow was archived</Alert>
{/if}
<div class="flex gap-2 flex-wrap mt-2">
@@ -358,7 +360,7 @@
</div>
<div class="flex flex-col gap-2 mt-2">
<div>
<div class="flex">
<Button
color="light"
size="sm"
@@ -424,9 +426,9 @@
color="red"
size="md"
startIcon={{ icon: faArchive }}
disabled={flow.archived || !can_write}
disabled={!can_write}
>
Archive
{flow.archived ? 'Unarchive' : 'Archive'}
</Button>
<Button
on:click={() => flow?.path && deleteFlow()}
@@ -434,7 +436,7 @@
color="red"
size="md"
startIcon={{ icon: faTrash }}
disabled={flow.archived || !can_write}
disabled={!can_write}
>
Delete
</Button>
@@ -23,7 +23,7 @@
async function loadFolders(): Promise<void> {
folders = (await FolderService.listFolders({ workspace: $workspaceStore! })).map((x) => {
return { canWrite: canWrite(x.name, x.extra_perms ?? {}, $userStore), ...x }
return { canWrite: canWrite('f/' + x.name, x.extra_perms ?? {}, $userStore), ...x }
})
}
@@ -57,7 +57,6 @@
let shareModal: ShareModal
$: loading = !script
$: if ($workspaceStore) {
loadScript($page.params.hash)
@@ -89,6 +88,21 @@
loadScript(hash)
}
async function unarchiveScript(hash: string): Promise<void> {
const r = await ScriptService.getScriptByHash({ workspace: $workspaceStore!, hash })
const ns = await ScriptService.createScript({
workspace: $workspaceStore!,
requestBody: {
...r,
parent_hash: hash,
lock: r.lock?.split('\n')
}
})
sendUserToast(`Unarchived script`)
loadScript(ns)
goto(`/scripts/get/${ns}`)
}
async function syncer(): Promise<void> {
if (script?.hash) {
const status = await ScriptService.getScriptDeploymentStatus({
@@ -119,7 +133,9 @@
workspace: $workspaceStore!,
path: script.path
}).catch((_) => console.error('this script has no non-archived version'))
topHash = script_by_path?.hash
if (script_by_path?.hash != script.hash) {
topHash = script_by_path?.hash
}
} else {
topHash = undefined
}
@@ -346,6 +362,7 @@
</div>
{/if}
{#if topHash}
<div class="mt-2" />
<Alert type="warning" title="Not HEAD">
This hash is not HEAD (latest non-archived version at this path) :
<a href="/scripts/get/{topHash}?workspace={$workspaceStore}"
@@ -354,7 +371,7 @@
</Alert>
{/if}
{#if script.archived && !topHash}
<Alert type="error" title="Archived">This version was archived</Alert>
<Alert type="error" title="Archived">This path was archived</Alert>
{/if}
{#if script.deleted}
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4" role="alert">
@@ -493,14 +510,16 @@
</div>
</TabContent>
{/each}
<Button
color="light"
size="sm"
endIcon={{ icon: viewWebhookCommand ? faChevronUp : faChevronDown }}
on:click={() => (viewWebhookCommand = !viewWebhookCommand)}
>
See example curl command
</Button>
<div class="flex">
<Button
color="light"
size="sm"
endIcon={{ icon: viewWebhookCommand ? faChevronUp : faChevronDown }}
on:click={() => (viewWebhookCommand = !viewWebhookCommand)}
>
See example curl command
</Button>
</div>
{#if viewWebhookCommand}
<div transition:slide|local class="px-4">
<!-- svelte-ignore a11y-click-events-have-key-events -->
@@ -534,17 +553,31 @@
</Button>
<span slot="text">require to be admin</span>
</Popover>
<Button
size="xs"
on:click={() => {
script?.hash && archiveScript(script.hash)
}}
color="red"
variant="border"
startIcon={{ icon: faArchive }}
>
Archive
</Button>
{#if script.archived}
<Button
size="xs"
on:click={() => {
script?.hash && unarchiveScript(script.hash)
}}
color="red"
variant="border"
startIcon={{ icon: faArchive }}
>
Unarchive
</Button>
{:else}
<Button
size="xs"
on:click={() => {
script?.hash && archiveScript(script.hash)
}}
color="red"
variant="border"
startIcon={{ icon: faArchive }}
>
Archive
</Button>
{/if}
</div>
{/if}
</div>
@@ -16,8 +16,7 @@
emptySchema,
emptyString,
getModifierKey,
sendUserToast,
truncateHash
sendUserToast
} from '$lib/utils'
import { faEye, faPen, faPlay } from '@fortawesome/free-solid-svg-icons'
import { Pane, Splitpanes } from 'svelte-splitpanes'
@@ -48,7 +47,9 @@
workspace: $workspaceStore!,
path: script.path
}).catch((_) => console.error('this script has no non-archived version'))
topHash = script_by_path?.hash
if (script_by_path?.hash != script.hash) {
topHash = script_by_path?.hash
}
} else {
topHash = undefined
}
@@ -120,10 +121,15 @@
{#if script}
<div class="flex flex-col justify-between gap-4 mb-6">
{#if topHash}
<div class="mt-2" />
<Alert type="warning" title="Not HEAD">
This hash is not HEAD (latest non-archived version at this path) :
<a href="/scripts/run/{topHash}">Go to the HEAD of this path</a>
</Alert>
{:else if script.archived}
<div class="mt-2" />
<Alert type="error" title="Archived">This path was archived</Alert>
{/if}
<div class="w-full">
<div class="flex flex-col mt-6 mb-2 w-full">
@@ -167,20 +173,17 @@
{defaultIfEmptyString(script.summary, script.path)}
</h1>
{#if !emptyString(script.summary)}
<h2 class="font-bold pb-4">{script.path}</h2>
<h2 class="font-normal text-gray-500 pb-2">{script.path}</h2>
{/if}
</div>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-gray-500">
<span class="text-xs text-gray-500">
{#if script}
Edited {displayDaysAgo(script.created_at || '')} by {script.created_by ||
'unknown'}
{/if}
</span>
<Badge color="dark-gray">
{truncateHash(script?.hash ?? '')}
</Badge>
{#if script?.is_template}
<Badge color="blue">Template</Badge>
{/if}
@@ -16,7 +16,7 @@
import type { ContextualVariable, ListableVariable } from '$lib/gen'
import { OauthService, VariableService } from '$lib/gen'
import { userStore, workspaceStore } from '$lib/stores'
import { canWrite, sendUserToast, truncate } from '$lib/utils'
import { canWrite, isOwner, sendUserToast, truncate } from '$lib/utils'
import {
faChain,
faCircle,
@@ -280,55 +280,58 @@
<td>
<Dropdown
placement="bottom-end"
dropdownItems={[
{
displayName: 'Edit',
icon: faEdit,
action: () => variableEditor.editVariable(path),
disabled: !canWrite
},
{
displayName: 'Delete',
icon: faTrash,
type: 'delete',
action: (event) => {
if (event?.shiftKey) {
deleteVariable(path, account)
} else {
deleteConfirmedCallback = () => {
dropdownItems={() => {
let owner = isOwner(path, $userStore, $workspaceStore)
return [
{
displayName: 'Edit',
icon: faEdit,
action: () => variableEditor.editVariable(path),
disabled: !canWrite
},
{
displayName: 'Delete',
icon: faTrash,
type: 'delete',
action: (event) => {
if (event?.shiftKey) {
deleteVariable(path, account)
}
}
},
disabled: !canWrite
},
{
displayName: canWrite ? 'Share' : 'See Permissions',
action: () => {
shareModal.openDrawer(path, 'variable')
},
icon: faShare
},
...(account != undefined
? [
{
displayName: 'Refresh token',
icon: faRefresh,
action: async () => {
await OauthService.refreshToken({
workspace: $workspaceStore ?? '',
id: account ?? 0,
requestBody: {
path
}
})
sendUserToast('Token refreshed')
loadVariables()
} else {
deleteConfirmedCallback = () => {
deleteVariable(path, account)
}
}
]
: [])
]}
},
disabled: !owner
},
{
displayName: owner ? 'Share' : 'See Permissions',
action: () => {
shareModal.openDrawer(path, 'variable')
},
icon: faShare
},
...(account != undefined
? [
{
displayName: 'Refresh token',
icon: faRefresh,
action: async () => {
await OauthService.refreshToken({
workspace: $workspaceStore ?? '',
id: account ?? 0,
requestBody: {
path
}
})
sendUserToast('Token refreshed')
loadVariables()
}
}
]
: [])
]
}}
/>
</td>
</tr>
+1 -1
View File
@@ -2,7 +2,7 @@
"compilerOptions": {
"moduleResolution": "node",
"module": "esnext",
"lib": ["esnext", "DOM"],
"lib": ["es2021", "DOM"],
"target": "esnext",
"preserveValueImports": true,
"outDir": "build",