From 2ff914edd3cd6dba6b9934e28611266dec07503c Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 19 Dec 2022 18:59:31 +0100 Subject: [PATCH] feat: introduce folders, deprecate items owned by groups --- backend/windmill-api/openapi.yaml | 198 ++++++++++- backend/windmill-api/src/apps.rs | 12 +- backend/windmill-api/src/db.rs | 28 ++ backend/windmill-api/src/granular_acls.rs | 12 +- backend/windmill-api/src/lib.rs | 4 +- frontend/src/lib/common.ts | 4 +- .../src/lib/components/FolderEditor.svelte | 317 ++++++++++++++++++ .../src/lib/components/GroupEditor.svelte | 56 ++-- frontend/src/lib/components/Path.svelte | 102 +++--- .../components/sidebar/SidebarContent.svelte | 6 +- frontend/src/lib/stores.ts | 1 + frontend/src/lib/utils.ts | 7 +- frontend/src/routes/folders.svelte | 149 ++++++++ frontend/src/routes/groups.svelte | 4 +- 14 files changed, 803 insertions(+), 97 deletions(-) create mode 100644 frontend/src/lib/components/FolderEditor.svelte create mode 100644 frontend/src/routes/folders.svelte diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 658d25031a..f08a02f95d 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1821,7 +1821,8 @@ paths: (default false) show also the archived files. when multiple archived hash share the same path, only the ones with the latest create_at - are displayed. + are + ed. in: query schema: type: boolean @@ -3620,6 +3621,172 @@ paths: schema: type: string + /w/{workspace}/folders/list: + get: + summary: list folders + operationId: listFolders + tags: + - folder + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - $ref: "#/components/parameters/Page" + - $ref: "#/components/parameters/PerPage" + responses: + "200": + description: folder list + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Folder" + + /w/{workspace}/folders/listnames: + get: + summary: list folder names + operationId: listFolderNames + tags: + - folder + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: only_member_of + in: query + description: only list the folders the user is member of (default false) + schema: + type: boolean + responses: + "200": + description: folder list + content: + application/json: + schema: + type: array + items: + type: string + + /w/{workspace}/folders/create: + post: + summary: create folder + operationId: createFolder + tags: + - folder + parameters: + - $ref: "#/components/parameters/WorkspaceId" + requestBody: + description: create folder + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + owners: + type: array + items: + type: string + extra_perms: + additionalProperties: + type: boolean + required: + - name + responses: + "200": + description: folder created + content: + text/plain: + schema: + type: string + + /w/{workspace}/folders/delete/{name}: + delete: + summary: delete folder + operationId: deleteFolder + tags: + - folder + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - $ref: "#/components/parameters/Name" + responses: + "200": + description: folder deleted + content: + text/plain: + schema: + type: string + + /w/{workspace}/folders/get/{name}: + get: + summary: get folder + operationId: getFolder + tags: + - folder + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - $ref: "#/components/parameters/Name" + responses: + "200": + description: folder + content: + application/json: + schema: + $ref: "#/components/schemas/Folder" + + /w/{workspace}/folders/addowner/{name}: + post: + summary: add owner to folder + operationId: addOwnerToFolder + tags: + - folder + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - $ref: "#/components/parameters/Name" + requestBody: + description: owner user to folder + required: true + content: + application/json: + schema: + type: object + properties: + owner: + type: string + responses: + "200": + description: owner added to folder + content: + text/plain: + schema: + type: string + + /w/{workspace}/folders/removeowner/{name}: + post: + summary: remove owner to folder + operationId: removeOwnerToFolder + tags: + - folder + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - $ref: "#/components/parameters/Name" + requestBody: + description: added owner to folder + required: true + content: + application/json: + schema: + type: object + properties: + owner: + type: string + responses: + "200": + description: owner removed from folder + content: + text/plain: + schema: + type: string + /workers/list: get: summary: list workers @@ -3653,7 +3820,7 @@ paths: required: true schema: type: string - enum: [script, group_, resource, schedule, variable, flow] + enum: [script, group_, resource, schedule, variable, flow, folder] responses: "200": description: acls @@ -3678,7 +3845,7 @@ paths: required: true schema: type: string - enum: [script, group_, resource, schedule, variable, flow] + enum: [script, group_, resource, schedule, variable, flow, folder] requestBody: description: acl to add required: true @@ -3714,7 +3881,7 @@ paths: required: true schema: type: string - enum: [script, group_, resource, schedule, variable, flow] + enum: [script, group_, resource, schedule, variable, flow, folder] requestBody: description: acl to add required: true @@ -4297,6 +4464,10 @@ components: type: array items: type: string + folders: + type: array + items: + type: string usage: $ref: "#/components/schemas/Usage" required: @@ -4307,6 +4478,7 @@ components: - created_at - operator - disabled + - folders Usage: type: object @@ -4832,6 +5004,24 @@ components: required: - name + Folder: + type: object + properties: + name: + type: string + owners: + type: array + items: + type: string + extra_perms: + type: object + additionalProperties: + type: boolean + required: + - name + - owners + - extra_perms + WorkerPing: type: object properties: diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index c143e54a0b..36b5b8bc80 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -62,7 +62,7 @@ pub struct ListableApp { #[derive(FromRow, Serialize, Deserialize)] pub struct AppVersion { pub id: i64, - pub flow_id: Uuid, + pub app_id: Uuid, pub value: serde_json::Value, pub created_by: String, pub created_at: chrono::DateTime, @@ -210,7 +210,7 @@ async fn get_app_by_id( "SELECT app.id, app.path, app.summary, app.versions, app.policy, app.extra_perms, app_version.value, app_version.created_at, app_version.created_by from app, app_version - WHERE app_version.id = $1 AND app.id = app_version.flow_id AND app.workspace_id = $2", + WHERE app_version.id = $1 AND app.id = app_version.app_id AND app.workspace_id = $2", id, &w_id ) @@ -244,7 +244,7 @@ async fn create_app( let v_id = sqlx::query_scalar!( "INSERT INTO app_version - (flow_id, value, created_by) + (app_id, value, created_by) VALUES ($1, $2, $3) RETURNING id", id, app.value, @@ -358,7 +358,7 @@ async fn update_app( "".to_string() }; if let Some(nvalue) = &ns.value { - let flow_id = sqlx::query_scalar!( + let app_id = sqlx::query_scalar!( "SELECT id FROM app WHERE path = $1 AND workspace_id = $2", path, w_id @@ -368,9 +368,9 @@ async fn update_app( let v_id = sqlx::query_scalar!( "INSERT INTO app_version - (flow_id, value, created_by) + (app_id, value, created_by) VALUES ($1, $2, $3) RETURNING id", - flow_id, + app_id, nvalue, authed.username, ) diff --git a/backend/windmill-api/src/db.rs b/backend/windmill-api/src/db.rs index 1b88c16340..7bbdfc2fca 100644 --- a/backend/windmill-api/src/db.rs +++ b/backend/windmill-api/src/db.rs @@ -72,6 +72,34 @@ impl UserDB { ) .fetch_optional(&mut tx) .await?; + + let (folders_write, folders_read): &(Vec<_>, Vec<_>) = + &authed.folders.clone().into_iter().partition(|x| x.1); + + let mut folders_read = folders_read.clone(); + folders_read.extend(folders_write.clone()); + sqlx::query!( + "SELECT set_config('session.folders_read', $1, true)", + folders_read + .iter() + .map(|x| x.0.clone()) + .collect::>() + .join(",") + ) + .fetch_optional(&mut tx) + .await?; + + sqlx::query!( + "SELECT set_config('session.folders_write', $1, true)", + folders_write + .iter() + .map(|x| x.0.clone()) + .collect::>() + .join(",") + ) + .fetch_optional(&mut tx) + .await?; + Ok(tx) } } diff --git a/backend/windmill-api/src/granular_acls.rs b/backend/windmill-api/src/granular_acls.rs index 22116ac7e1..d0a94ccd39 100644 --- a/backend/windmill-api/src/granular_acls.rs +++ b/backend/windmill-api/src/granular_acls.rs @@ -44,7 +44,11 @@ async fn add_granular_acl( .ok_or_else(|| Error::BadRequest("Invalid path or kind".to_string()))?; let mut tx = user_db.begin(&authed).await?; - let identifier = if kind == "group_" { "name" } else { "path" }; + let identifier = if kind == "group_" || kind == "folder" { + "name" + } else { + "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" @@ -73,7 +77,11 @@ async fn remove_granular_acl( .ok_or_else(|| Error::BadRequest("Invalid path or kind".to_string()))?; let mut tx = user_db.begin(&authed).await?; - let identifier = if kind == "group_" { "name" } else { "path" }; + let identifier = if kind == "group_" || kind == "folder" { + "name" + } else { + "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" diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index 0169032ab0..bb6d297efa 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -29,6 +29,7 @@ mod capture; mod db; mod favorite; mod flows; +mod folders; mod granular_acls; mod groups; pub mod jobs; @@ -126,7 +127,8 @@ pub async fn run_server( .nest("/flows", flows::workspaced_service()) .nest("/capture", capture::workspaced_service()) .nest("/apps", apps::workspaced_service()) - .nest("/favorites", favorite::workspaced_service()), + .nest("/favorites", favorite::workspaced_service()) + .nest("/folders", folders::workspaced_service()), ) .nest("/workspaces", workspaces::global_service()) .nest( diff --git a/frontend/src/lib/common.ts b/frontend/src/lib/common.ts index a010ac00d0..437c36884a 100644 --- a/frontend/src/lib/common.ts +++ b/frontend/src/lib/common.ts @@ -1,6 +1,6 @@ import type { Script } from "./gen" -export type OwnerKind = 'group' | 'user' +export type OwnerKind = 'group' | 'user' | 'folder' export type ActionKind = 'Create' | 'Update' | 'Delete' | 'Execute' @@ -32,6 +32,8 @@ export function pathToMeta(path: string): Meta { let ownerKind: OwnerKind if (splitted[0] == 'g') { ownerKind = 'group' + } else if (splitted[0] == 'f') { + ownerKind = 'folder' } else if (splitted[0] == 'u') { ownerKind = 'user' } else { diff --git a/frontend/src/lib/components/FolderEditor.svelte b/frontend/src/lib/components/FolderEditor.svelte new file mode 100644 index 0000000000..f3a767aaef --- /dev/null +++ b/frontend/src/lib/components/FolderEditor.svelte @@ -0,0 +1,317 @@ + + +
+

{name}

+

Permissions

+ {#if can_write} +
+
+ + User + Group + +
+ + +
+ {/if} + {#if perms} + + + user + + + + + {#each perms as { owner_name, role }} + {owner_name} + + {#if can_write} +
+ { + const role = e.detail + // const wasInFolder = (folder?.owners ?? []).includes(folder) + // const inAcl = ( + // folder?.extra_perms ? Object.keys(folder?.extra_perms) : [] + // ).includes(folder) + if (role == 'owner') { + await FolderService.addOwnerToFolder({ + workspace: $workspaceStore ?? '', + name, + requestBody: { + owner: owner_name + } + }) + await GranularAclService.addGranularAcls({ + workspace: $workspaceStore ?? '', + path: name, + kind: 'folder', + requestBody: { + owner: owner_name + } + }) + } else if (role == 'writer') { + await FolderService.removeOwnerToFolder({ + workspace: $workspaceStore ?? '', + name, + requestBody: { + owner: owner_name + } + }) + await GranularAclService.addGranularAcls({ + workspace: $workspaceStore ?? '', + path: name, + kind: 'folder', + requestBody: { + owner: owner_name, + write: true + } + }) + } else if (role == 'viewer') { + await FolderService.removeOwnerToFolder({ + workspace: $workspaceStore ?? '', + name, + requestBody: { + owner: owner_name + } + }) + await GranularAclService.addGranularAcls({ + workspace: $workspaceStore ?? '', + path: name, + kind: 'folder', + requestBody: { + owner: owner_name, + write: false + } + }) + } + loadFolder() + }} + > + Viewer A viewer of a folder has read-only access to all the elements + (scripts/flows/apps/schedules/resources/variables) inside the folder + Writer A viewer of a folder has read AND write access to all the elements + (scripts/flows/apps/schedules/resources/variables) inside the folder + Admin An admin of a folder has read AND write access to all the elements inside + the folders and can manage the permissions as well as add new admins + +
+ {/if} + + {#if can_write} + + {/if} + {/each} + +
+ + + {:else} +
+ {#each new Array(6) as _} + + {/each} +
+ {/if} +
diff --git a/frontend/src/lib/components/GroupEditor.svelte b/frontend/src/lib/components/GroupEditor.svelte index 98509db13d..4d20ca6af0 100644 --- a/frontend/src/lib/components/GroupEditor.svelte +++ b/frontend/src/lib/components/GroupEditor.svelte @@ -3,10 +3,12 @@ import { type Group, GroupService, UserService, GranularAclService } from '$lib/gen' import AutoComplete from 'simple-svelte-autocomplete' import TableCustom from './TableCustom.svelte' - import { canWrite } from '$lib/utils' + import { canWrite, sendUserToast } from '$lib/utils' import { Button, ToggleButton, ToggleButtonGroup } from './common' import Skeleton from './common/skeleton/Skeleton.svelte' import Tooltip from './Tooltip.svelte' + import autosize from 'svelte-autosize' + import { createEventDispatcher } from 'svelte' export let name: string let can_write = false @@ -19,6 +21,8 @@ let username: string = '' let groups: string[] = [] + const dispatch = createEventDispatcher() + async function loadUsernames(): Promise { usernames = await UserService.listUsernames({ workspace: $workspaceStore! }) } @@ -48,19 +52,6 @@ loadGroup() } - async function addToManagingGroup() { - await GranularAclService.addGranularAcls({ - workspace: $workspaceStore ?? '', - path: name, - kind: 'group_', - requestBody: { - owner: 'g/' + new_managing_group, - write: true - } - }) - loadGroup() - } - async function loadGroup(): Promise { group = await GroupService.getGroup({ workspace: $workspaceStore!, name }) can_write = canWrite(name!, group.extra_perms ?? {}, $userStore) @@ -85,7 +76,7 @@ function getRole(x: string): Role { const writer = 'u/' + x in (group?.extra_perms ?? {}) && (group?.extra_perms ?? {})['u/' + x] const member = group?.members?.includes(x) - console.log(writer, member, x, group?.members, group?.extra_perms) + if (writer && member) { return 'member_manager' } else if (writer) { @@ -94,14 +85,33 @@ return 'member' } } - - let new_managing_group = ''

{name}

{#if group} -

{group?.summary ?? 'No summary'}

+
+