diff --git a/backend/.sqlx/query-07168aaf14cb6beff0ad4274b441f7f387f5055c47f493271d26731336257384.json b/backend/.sqlx/query-07168aaf14cb6beff0ad4274b441f7f387f5055c47f493271d26731336257384.json index d29a18c691..e7ed0aee65 100644 --- a/backend/.sqlx/query-07168aaf14cb6beff0ad4274b441f7f387f5055c47f493271d26731336257384.json +++ b/backend/.sqlx/query-07168aaf14cb6beff0ad4274b441f7f387f5055c47f493271d26731336257384.json @@ -46,11 +46,11 @@ ] }, "nullable": [ - true, - true, - true, - true, - true, + false, + false, + false, + false, + false, true, true ] diff --git a/backend/.sqlx/query-3d05d9d7e087eb6e1c14c2b8a20598581e6c7493ed99cb9ad1c2ee5d0b212d38.json b/backend/.sqlx/query-3d05d9d7e087eb6e1c14c2b8a20598581e6c7493ed99cb9ad1c2ee5d0b212d38.json index 6f2da5c6db..521b7cb1c0 100644 --- a/backend/.sqlx/query-3d05d9d7e087eb6e1c14c2b8a20598581e6c7493ed99cb9ad1c2ee5d0b212d38.json +++ b/backend/.sqlx/query-3d05d9d7e087eb6e1c14c2b8a20598581e6c7493ed99cb9ad1c2ee5d0b212d38.json @@ -59,9 +59,7 @@ "failure", "command", "approval", - "preprocessor", - "schedule_handler_old", - "dynamic_skip" + "preprocessor" ] } } diff --git a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json index 713ccb9dd3..36ddb8ab9f 100644 --- a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json +++ b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json @@ -15,7 +15,7 @@ ] }, "nullable": [ - null + true ] }, "hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55" diff --git a/backend/.sqlx/query-e500a422fa986faf5c612c08f4a139e10b25a3106551e7c92f43dcf9758a5ec7.json b/backend/.sqlx/query-a3ca0af5d84acba93776828c090a79bf6554180b9db4951b69f7070f769b2962.json similarity index 66% rename from backend/.sqlx/query-e500a422fa986faf5c612c08f4a139e10b25a3106551e7c92f43dcf9758a5ec7.json rename to backend/.sqlx/query-a3ca0af5d84acba93776828c090a79bf6554180b9db4951b69f7070f769b2962.json index f249de4dc9..4f7d599a05 100644 --- a/backend/.sqlx/query-e500a422fa986faf5c612c08f4a139e10b25a3106551e7c92f43dcf9758a5ec7.json +++ b/backend/.sqlx/query-a3ca0af5d84acba93776828c090a79bf6554180b9db4951b69f7070f769b2962.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE folder SET owners = array_remove(owners, $1::varchar) WHERE name = $2 AND workspace_id = $3 RETURNING name", + "query": "UPDATE folder SET owners = array_remove(owners, $1::varchar) WHERE name = $2 AND workspace_id = $3 AND $1 = ANY(owners) RETURNING name", "describe": { "columns": [ { @@ -11,7 +11,7 @@ ], "parameters": { "Left": [ - "Varchar", + "Text", "Text", "Text" ] @@ -20,5 +20,5 @@ false ] }, - "hash": "e500a422fa986faf5c612c08f4a139e10b25a3106551e7c92f43dcf9758a5ec7" + "hash": "a3ca0af5d84acba93776828c090a79bf6554180b9db4951b69f7070f769b2962" } diff --git a/backend/.sqlx/query-b11034489786626184e2f6b7f99469a945a4bbfe0e6a33c5a3e009dadd56d52c.json b/backend/.sqlx/query-b11034489786626184e2f6b7f99469a945a4bbfe0e6a33c5a3e009dadd56d52c.json new file mode 100644 index 0000000000..9e61a0d7d0 --- /dev/null +++ b/backend/.sqlx/query-b11034489786626184e2f6b7f99469a945a4bbfe0e6a33c5a3e009dadd56d52c.json @@ -0,0 +1,49 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, changed_by, changed_at, change_type, member_affected\n FROM group_permission_history\n WHERE workspace_id = $1 AND group_name = $2\n ORDER BY id DESC\n LIMIT $3 OFFSET $4", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "changed_by", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "changed_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "change_type", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "member_affected", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Int8", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + true + ] + }, + "hash": "b11034489786626184e2f6b7f99469a945a4bbfe0e6a33c5a3e009dadd56d52c" +} diff --git a/backend/.sqlx/query-b24eeafc2fc26664ef38d15d3cb47ca19549bcd9372d7a61ec2e28d82897bacf.json b/backend/.sqlx/query-b24eeafc2fc26664ef38d15d3cb47ca19549bcd9372d7a61ec2e28d82897bacf.json new file mode 100644 index 0000000000..52971aeb1e --- /dev/null +++ b/backend/.sqlx/query-b24eeafc2fc26664ef38d15d3cb47ca19549bcd9372d7a61ec2e28d82897bacf.json @@ -0,0 +1,49 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, changed_by, changed_at, change_type, affected\n FROM folder_permission_history\n WHERE workspace_id = $1 AND folder_name = $2\n ORDER BY id DESC\n LIMIT $3 OFFSET $4", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "changed_by", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "changed_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "change_type", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "affected", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Int8", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + true + ] + }, + "hash": "b24eeafc2fc26664ef38d15d3cb47ca19549bcd9372d7a61ec2e28d82897bacf" +} diff --git a/backend/.sqlx/query-b3dbdfb50ee8118bdaed3164b210cb549a34b96554ae1872355b90304f5dcb76.json b/backend/.sqlx/query-b3dbdfb50ee8118bdaed3164b210cb549a34b96554ae1872355b90304f5dcb76.json index 99269c9851..54e94cfb8f 100644 --- a/backend/.sqlx/query-b3dbdfb50ee8118bdaed3164b210cb549a34b96554ae1872355b90304f5dcb76.json +++ b/backend/.sqlx/query-b3dbdfb50ee8118bdaed3164b210cb549a34b96554ae1872355b90304f5dcb76.json @@ -18,8 +18,8 @@ "Left": [] }, "nullable": [ - true, - false + false, + true ] }, "hash": "b3dbdfb50ee8118bdaed3164b210cb549a34b96554ae1872355b90304f5dcb76" diff --git a/backend/.sqlx/query-f1206b00c4b81e59943385646d653330efba9cd1e731621f7efc91f04567841f.json b/backend/.sqlx/query-f1206b00c4b81e59943385646d653330efba9cd1e731621f7efc91f04567841f.json new file mode 100644 index 0000000000..d03eac80b6 --- /dev/null +++ b/backend/.sqlx/query-f1206b00c4b81e59943385646d653330efba9cd1e731621f7efc91f04567841f.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO folder_permission_history\n (workspace_id, folder_name, changed_by, change_type, affected)\n VALUES ($1, $2, $3, $4, $5)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "f1206b00c4b81e59943385646d653330efba9cd1e731621f7efc91f04567841f" +} diff --git a/backend/.sqlx/query-f95358255e55d68dd453d173e23ab3cb1f1c2ba5b1cfc78f706b0b014f045477.json b/backend/.sqlx/query-f95358255e55d68dd453d173e23ab3cb1f1c2ba5b1cfc78f706b0b014f045477.json new file mode 100644 index 0000000000..492bf20427 --- /dev/null +++ b/backend/.sqlx/query-f95358255e55d68dd453d173e23ab3cb1f1c2ba5b1cfc78f706b0b014f045477.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO group_permission_history\n (workspace_id, group_name, changed_by, change_type, member_affected)\n VALUES ($1, $2, $3, $4, $5)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "f95358255e55d68dd453d173e23ab3cb1f1c2ba5b1cfc78f706b0b014f045477" +} diff --git a/backend/migrations/20251030104400_folder_group_permission_history.down.sql b/backend/migrations/20251030104400_folder_group_permission_history.down.sql new file mode 100644 index 0000000000..9821b4d735 --- /dev/null +++ b/backend/migrations/20251030104400_folder_group_permission_history.down.sql @@ -0,0 +1,6 @@ +-- Add down migration script here +DROP INDEX IF EXISTS idx_group_perm_history_workspace_group; +DROP TABLE IF EXISTS group_permission_history; + +DROP INDEX IF EXISTS idx_folder_perm_history_workspace_folder; +DROP TABLE IF EXISTS folder_permission_history; diff --git a/backend/migrations/20251030104400_folder_group_permission_history.up.sql b/backend/migrations/20251030104400_folder_group_permission_history.up.sql new file mode 100644 index 0000000000..069e648648 --- /dev/null +++ b/backend/migrations/20251030104400_folder_group_permission_history.up.sql @@ -0,0 +1,90 @@ +-- Add up migration script here + +-- Folder permission changes history +CREATE TABLE IF NOT EXISTS folder_permission_history ( + id BIGSERIAL PRIMARY KEY, + workspace_id VARCHAR(50) NOT NULL, + folder_name VARCHAR(255) NOT NULL, + changed_by VARCHAR(50) NOT NULL, + changed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + change_type VARCHAR(50) NOT NULL, + affected VARCHAR(100), + FOREIGN KEY (workspace_id, folder_name) REFERENCES folder(workspace_id, name) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_folder_perm_history_workspace_folder + ON folder_permission_history(workspace_id, folder_name, id DESC); + +-- Group permission changes history +CREATE TABLE IF NOT EXISTS group_permission_history ( + id BIGSERIAL PRIMARY KEY, + workspace_id VARCHAR(50) NOT NULL, + group_name VARCHAR(255) NOT NULL, + changed_by VARCHAR(50) NOT NULL, + changed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + change_type VARCHAR(50) NOT NULL, + member_affected VARCHAR(100), + FOREIGN KEY (workspace_id, group_name) REFERENCES group_(workspace_id, name) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_group_perm_history_workspace_group + ON group_permission_history(workspace_id, group_name, id DESC); + +GRANT ALL ON TABLE folder_permission_history TO windmill_user; +GRANT ALL ON TABLE group_permission_history TO windmill_user; +GRANT ALL ON TABLE folder_permission_history TO windmill_admin; +GRANT ALL ON TABLE group_permission_history TO windmill_admin; + +-- Enable RLS on folder_permission_history +ALTER TABLE folder_permission_history ENABLE ROW LEVEL SECURITY; + +-- Admin policies (windmill_admin can always do everything) +CREATE POLICY admin_all ON folder_permission_history FOR ALL TO windmill_admin USING (true) WITH CHECK (true); +CREATE POLICY admin_all ON group_permission_history FOR ALL TO windmill_admin USING (true) WITH CHECK (true); + +-- Enable RLS on group_permission_history +ALTER TABLE group_permission_history ENABLE ROW LEVEL SECURITY; + +-- RLS policies for folder_permission_history +-- Anyone can insert +CREATE POLICY allow_insert ON folder_permission_history FOR INSERT TO windmill_user WITH CHECK (true); + +-- Select requires being in extra_perms (as user or via group) +CREATE POLICY see_extra_perms_user ON folder_permission_history FOR SELECT TO windmill_user +USING (EXISTS ( + SELECT 1 FROM folder f + WHERE f.workspace_id = folder_permission_history.workspace_id + AND f.name = folder_permission_history.folder_name + AND f.extra_perms ? CONCAT('u/', current_setting('session.user')) +)); + +CREATE POLICY see_extra_perms_groups ON folder_permission_history FOR SELECT TO windmill_user +USING (EXISTS ( + SELECT 1 FROM folder f + WHERE f.workspace_id = folder_permission_history.workspace_id + AND f.name = folder_permission_history.folder_name + AND f.extra_perms ?| regexp_split_to_array(current_setting('session.pgroups'), ',')::text[] +)); + +-- RLS policies for group_permission_history +-- Anyone can insert +CREATE POLICY allow_insert ON group_permission_history FOR INSERT TO windmill_user WITH CHECK (true); + +-- Select requires being in extra_perms (as user or via group) +CREATE POLICY see_extra_perms_user ON group_permission_history FOR SELECT TO windmill_user +USING (EXISTS ( + SELECT 1 FROM group_ g + WHERE g.workspace_id = group_permission_history.workspace_id + AND g.name = group_permission_history.group_name + AND (g.extra_perms ->> CONCAT('u/', current_setting('session.user')))::boolean +)); + +CREATE POLICY see_extra_perms_groups ON group_permission_history FOR SELECT TO windmill_user +USING (EXISTS ( + SELECT 1 FROM group_ g, jsonb_each_text(g.extra_perms) f + WHERE g.workspace_id = group_permission_history.workspace_id + AND g.name = group_permission_history.group_name + AND SPLIT_PART(f.key, '/', 1) = 'g' + AND f.key = ANY(regexp_split_to_array(current_setting('session.pgroups'), ',')::text[]) + AND f.value::boolean +)); diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 574e9be6ca..e4f6327368 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -13021,6 +13021,40 @@ paths: schema: type: string + /w/{workspace}/groups_history/get/{name}: + get: + summary: get group permission history + operationId: getGroupPermissionHistory + tags: + - group + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - $ref: "#/components/parameters/Name" + - $ref: "#/components/parameters/Page" + - $ref: "#/components/parameters/PerPage" + responses: + "200": + description: group permission history + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: integer + changed_by: + type: string + changed_at: + type: string + format: date-time + change_type: + type: string + member_affected: + type: string + nullable: true + /w/{workspace}/folders/list: get: summary: list folders @@ -13284,6 +13318,40 @@ paths: schema: type: string + /w/{workspace}/folders_history/get/{name}: + get: + summary: get folder permission history + operationId: getFolderPermissionHistory + tags: + - folder + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - $ref: "#/components/parameters/Name" + - $ref: "#/components/parameters/Page" + - $ref: "#/components/parameters/PerPage" + responses: + "200": + description: folder permission history + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: integer + changed_by: + type: string + changed_at: + type: string + format: date-time + change_type: + type: string + affected: + type: string + nullable: true + /workers/list: get: summary: list workers diff --git a/backend/windmill-api/src/folder_history.rs b/backend/windmill-api/src/folder_history.rs new file mode 100644 index 0000000000..581b568eb1 --- /dev/null +++ b/backend/windmill-api/src/folder_history.rs @@ -0,0 +1,68 @@ +/* + * Author: Ruben Fiszel + * Copyright: Windmill Labs, Inc 2022 + * This file and its contents are licensed under the AGPLv3 License. + * Please see the included NOTICE for copyright information and + * LICENSE-AGPL for a copy of the license. + */ + +use crate::db::ApiAuthed; +use axum::{ + extract::{Extension, Path, Query}, + routing::get, + Router, +}; +use windmill_common::{ + db::UserDB, + error::JsonResult, + utils::{paginate, Pagination}, +}; + +use serde::Serialize; +use sqlx::FromRow; + +pub fn workspaced_service() -> Router { + Router::new().route("/get/:name", get(get_folder_permission_history)) +} + +#[derive(Serialize, FromRow)] +pub struct FolderPermissionChange { + pub id: i64, + pub changed_by: String, + pub changed_at: chrono::DateTime, + pub change_type: String, + pub affected: Option, +} + +async fn get_folder_permission_history( + authed: ApiAuthed, + Extension(user_db): Extension, + Path((w_id, name)): Path<(String, String)>, + Query(pagination): Query, +) -> JsonResult> { + // Check if user is owner of the folder (before starting transaction for performance) + crate::folders::require_is_owner(&authed, &name)?; + + let mut tx = user_db.begin(&authed).await?; + + let (per_page, offset) = paginate(pagination); + + let history = sqlx::query_as!( + FolderPermissionChange, + "SELECT id, changed_by, changed_at, change_type, affected + FROM folder_permission_history + WHERE workspace_id = $1 AND folder_name = $2 + ORDER BY id DESC + LIMIT $3 OFFSET $4", + w_id, + name, + per_page as i64, + offset as i64 + ) + .fetch_all(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(axum::Json(history)) +} diff --git a/backend/windmill-api/src/folders.rs b/backend/windmill-api/src/folders.rs index 64a792a982..5e4ba7db96 100644 --- a/backend/windmill-api/src/folders.rs +++ b/backend/windmill-api/src/folders.rs @@ -261,6 +261,17 @@ async fn create_folder( None, ) .await?; + + log_folder_permission_change( + &mut *tx, + &w_id, + &ng.name, + &authed.username, + "create", + None, + ) + .await?; + tx.commit().await?; handle_deployment_metadata( @@ -332,6 +343,10 @@ async fn update_folder( sqlb.set("edited_at", "now()"); + // Track whether permission-related fields are being updated + let owners_changed = ng.owners.is_some(); + let extra_perms_changed = ng.extra_perms.is_some(); + if !authed.is_admin { let prefixed_username = format!("u/{}", authed.username); if ng.owners.as_ref().is_some_and(|x| { @@ -418,6 +433,31 @@ async fn update_folder( None, ) .await?; + + // Log permission changes if owners or extra_perms were updated + if owners_changed { + log_folder_permission_change( + &mut *tx, + &w_id, + &name, + &authed.username, + "update_owners", + None, + ) + .await?; + } + if extra_perms_changed { + log_folder_permission_change( + &mut *tx, + &w_id, + &name, + &authed.username, + "update_acl", + None, + ) + .await?; + } + tx.commit().await?; handle_deployment_metadata( @@ -675,6 +715,17 @@ async fn add_owner( Some([("owner", owner.as_str())].into()), ) .await?; + + log_folder_permission_change( + &mut *tx, + &w_id, + &name, + &authed.username, + "grant_admin", + Some(&owner), + ) + .await?; + tx.commit().await?; webhook.send_message( @@ -697,8 +748,8 @@ async fn remove_owner( not_found_if_none(get_folderopt(&mut tx, &w_id, &name).await?, "Folder", &name)?; require_is_owner(&authed, &name)?; - sqlx::query!( - "UPDATE folder SET owners = array_remove(owners, $1::varchar) WHERE name = $2 AND workspace_id = $3 RETURNING name", + let folder = sqlx::query!( + "UPDATE folder SET owners = array_remove(owners, $1::varchar) WHERE name = $2 AND workspace_id = $3 AND $1 = ANY(owners) RETURNING name", owner, &name, &w_id, @@ -706,18 +757,29 @@ async fn remove_owner( .fetch_optional(&mut *tx) .await?; + if folder.is_none() && write.is_none() { + return Ok(format!("Owner {} is already not a member of folder {}", owner, name)); + } + if let Some(write) = write { - sqlx::query(&format!( - "UPDATE folder SET extra_perms = jsonb_set(extra_perms, '{{\"{owner}\"}}', to_jsonb($1), \ - true) WHERE name = $2 AND workspace_id = $3 RETURNING extra_perms" - )) + let old_write = sqlx::query_scalar::<_, Option>(&format!( + "UPDATE folder SET extra_perms = jsonb_set(extra_perms, '{{\"{owner}\"}}', to_jsonb($1), \ + true) FROM (SELECT (extra_perms->>'{owner}')::boolean as old_val FROM folder WHERE name = $2 AND workspace_id = $3) old \ + WHERE name = $2 AND workspace_id = $3 RETURNING old.old_val" + )) .bind(write) .bind(&name) .bind(&w_id) .fetch_optional(&mut *tx) - .await?; + .await? + .flatten(); + + if folder.is_none() && old_write.is_none_or(|ow| ow == write) { + return Ok(format!("Owner {} is already not a member of folder {} and write permission was already {}", owner, name, write)); + } } + audit_log( &mut *tx, &authed, @@ -728,6 +790,14 @@ async fn remove_owner( Some([("owner", owner.as_str())].into()), ) .await?; + + let change_type = match write { + Some(true) => "grant_writer_only", + Some(false) => "grant_viewer_only", + None => "revoke_all", + }; + log_folder_permission_change(&mut *tx, &w_id, &name, &authed.username, change_type, Some(&owner)).await?; + tx.commit().await?; webhook.send_message( @@ -737,3 +807,26 @@ async fn remove_owner( Ok(format!("Removed {} to folder {}", owner, name)) } + +pub async fn log_folder_permission_change<'c, E: sqlx::Executor<'c, Database = Postgres>>( + db: E, + workspace_id: &str, + folder_name: &str, + changed_by: &str, + change_type: &str, + affected: Option<&str>, +) -> Result<()> { + sqlx::query!( + "INSERT INTO folder_permission_history + (workspace_id, folder_name, changed_by, change_type, affected) + VALUES ($1, $2, $3, $4, $5)", + workspace_id, + folder_name, + changed_by, + change_type, + affected + ) + .execute(db) + .await?; + Ok(()) +} diff --git a/backend/windmill-api/src/granular_acls.rs b/backend/windmill-api/src/granular_acls.rs index a9612d4a7e..97445e0859 100644 --- a/backend/windmill-api/src/granular_acls.rs +++ b/backend/windmill-api/src/granular_acls.rs @@ -116,7 +116,7 @@ async fn add_granular_acl( "UPDATE {kind} SET extra_perms = jsonb_set(extra_perms, $1, to_jsonb($2), \ true) WHERE {identifier} = $3 AND workspace_id = $4 RETURNING extra_perms" )) - .bind(vec![owner]) + .bind(vec![owner.clone()]) .bind(write.unwrap_or(false)) .bind(path) .bind(&w_id) @@ -124,6 +124,39 @@ async fn add_granular_acl( .await?; let _ = not_found_if_none(obj_o, &kind, &path)?; + + if kind == "folder" { + let change_type = if write.unwrap_or(false) { + "grant_read" + } else { + "grant_write" + }; + crate::folders::log_folder_permission_change( + &mut *tx, + &w_id, + path, + &authed.username, + change_type, + Some(&owner), + ) + .await?; + } else if kind == "group_" { + let change_type = if write.unwrap_or(false) { + "grant_admin" + } else { + "grant_member_only" + }; + crate::groups::log_group_permission_change( + &mut *tx, + &w_id, + path, + &authed.username, + change_type, + Some(&owner), + ) + .await?; + } + tx.commit().await?; match kind { @@ -190,7 +223,7 @@ async fn remove_granular_acl( Extension(db): Extension, Extension(user_db): Extension, Path((w_id, path)): Path<(String, StripPath)>, - Json(GranularAcl { owner, write: _ }): Json, + Json(GranularAcl { owner, .. }): Json, ) -> Result { let path = path.to_path(); @@ -225,73 +258,104 @@ async fn remove_granular_acl( 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" + let obj_o = sqlx::query_scalar::<_, bool>(&format!( + "WITH old AS ( + SELECT extra_perms->$1 as old_write FROM {kind} + WHERE {identifier} = $2 AND workspace_id = $3 AND extra_perms ? $1 + ) + UPDATE {kind} SET extra_perms = extra_perms - $1 + WHERE {identifier} = $2 AND workspace_id = $3 AND extra_perms ? $1 + RETURNING (SELECT old_write FROM old)::bool" )) - .bind(owner) + .bind(&owner) .bind(path) .bind(&w_id) .fetch_optional(&mut *tx) .await?; - let _ = not_found_if_none(obj_o, &kind, &path)?; - tx.commit().await?; - - match kind { - "folder" => { - handle_deployment_metadata( - &authed.email, - &authed.username, - &db, + // Only log if something was actually removed (obj_o is Some) + if let Some(write) = obj_o { + // Log permission changes for folders and groups + if kind == "folder" { + let change_type = if write { "revoke_write" } else { "revoke_read" }; + crate::folders::log_folder_permission_change( + &mut *tx, &w_id, - DeployedObject::Folder { path: format!("f/{}", path) }, - Some(format!("Folder '{}' changed permissions", path)), - true, + path, + &authed.username, + change_type, + Some(&owner), ) - .await? + .await?; + } else if kind == "group_" { + crate::groups::log_group_permission_change( + &mut *tx, + &w_id, + path, + &authed.username, + "revoke_admin", + Some(&owner), + ) + .await?; + } + + tx.commit().await?; + + match kind { + "folder" => { + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + DeployedObject::Folder { path: format!("f/{}", path) }, + Some(format!("Folder '{}' changed permissions", path)), + true, + ) + .await? + } + // "app" => { + // handle_deployment_metadata( + // &authed.email, + // &authed.username, + // &db, + // &w_id, + // DeployedObject::App { path: path.to_string(), parent_path: None, version: 0 }, + // Some(format!("App '{}' changed permissions", path)), + // // true, + // ) + // .await? + // } + // "script" => { + // handle_deployment_metadata( + // &authed.email, + // &authed.username, + // &db, + // &w_id, + // DeployedObject::Script { + // path: path.to_string(), + // parent_path: None, + // hash: ScriptHash(0), + // }, + // Some(format!("Script '{}' changed permissions", path)), + // // true, + // ) + // .await? + // } + // "flow" => { + // handle_deployment_metadata( + // &authed.email, + // &authed.username, + // &db, + // &w_id, + // DeployedObject::Flow { path: path.to_string(), parent_path: None }, + // Some(format!("Flow '{}' changed permissions", path)), + // // true, + // ) + // .await? + // } + _ => (), } - // "app" => { - // handle_deployment_metadata( - // &authed.email, - // &authed.username, - // &db, - // &w_id, - // DeployedObject::App { path: path.to_string(), parent_path: None, version: 0 }, - // Some(format!("App '{}' changed permissions", path)), - // // true, - // ) - // .await? - // } - // "script" => { - // handle_deployment_metadata( - // &authed.email, - // &authed.username, - // &db, - // &w_id, - // DeployedObject::Script { - // path: path.to_string(), - // parent_path: None, - // hash: ScriptHash(0), - // }, - // Some(format!("Script '{}' changed permissions", path)), - // // true, - // ) - // .await? - // } - // "flow" => { - // handle_deployment_metadata( - // &authed.email, - // &authed.username, - // &db, - // &w_id, - // DeployedObject::Flow { path: path.to_string(), parent_path: None }, - // Some(format!("Flow '{}' changed permissions", path)), - // // true, - // ) - // .await? - // } - _ => (), } Ok("Successfully removed granular acl".to_string()) diff --git a/backend/windmill-api/src/group_history.rs b/backend/windmill-api/src/group_history.rs new file mode 100644 index 0000000000..9990050d29 --- /dev/null +++ b/backend/windmill-api/src/group_history.rs @@ -0,0 +1,66 @@ +/* + * Author: Ruben Fiszel + * Copyright: Windmill Labs, Inc 2022 + * This file and its contents are licensed under the AGPLv3 License. + * Please see the included NOTICE for copyright information and + * LICENSE-AGPL for a copy of the license. + */ + +use crate::db::ApiAuthed; +use axum::{ + extract::{Extension, Path, Query}, + routing::get, + Router, +}; +use windmill_common::{ + db::UserDB, + error::{Error, JsonResult}, + utils::{paginate, Pagination}, +}; + +use serde::Serialize; +use sqlx::FromRow; + +pub fn workspaced_service() -> Router { + Router::new().route("/get/:name", get(get_group_permission_history)) +} + +#[derive(Serialize, FromRow)] +pub struct GroupPermissionChange { + pub id: i64, + pub changed_by: String, + pub changed_at: chrono::DateTime, + pub change_type: String, + pub member_affected: Option, +} + +async fn get_group_permission_history( + authed: ApiAuthed, + Extension(user_db): Extension, + Path((w_id, name)): Path<(String, String)>, + Query(pagination): Query, +) -> JsonResult> { + + let mut tx = user_db.begin(&authed).await?; + + let (per_page, offset) = paginate(pagination); + + let history = sqlx::query_as!( + GroupPermissionChange, + "SELECT id, changed_by, changed_at, change_type, member_affected + FROM group_permission_history + WHERE workspace_id = $1 AND group_name = $2 + ORDER BY id DESC + LIMIT $3 OFFSET $4", + w_id, + name, + per_page as i64, + offset as i64 + ) + .fetch_all(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(axum::Json(history)) +} diff --git a/backend/windmill-api/src/groups.rs b/backend/windmill-api/src/groups.rs index 5f89656ef3..b9681972b2 100644 --- a/backend/windmill-api/src/groups.rs +++ b/backend/windmill-api/src/groups.rs @@ -271,6 +271,16 @@ async fn create_group( ) .await?; + log_group_permission_change( + &mut *tx, + &w_id, + &ng.name, + &authed.username, + "create", + None, + ) + .await?; + tx.commit().await?; handle_deployment_metadata( @@ -542,6 +552,17 @@ async fn update_group( None, ) .await?; + + log_group_permission_change( + &mut *tx, + &w_id, + &name, + &authed.username, + "update_summary", + None, + ) + .await?; + tx.commit().await?; handle_deployment_metadata( @@ -572,7 +593,7 @@ async fn add_user( not_found_if_none(get_group_opt(&mut tx, &w_id, &name).await?, "Group", &name)?; - sqlx::query!( + let result = sqlx::query!( "INSERT INTO usr_to_group (workspace_id, usr, group_) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING", &w_id, user_username, @@ -581,6 +602,10 @@ async fn add_user( .execute(&mut *tx) .await?; + if result.rows_affected() == 0 { + return Ok(format!("{} is already a member of group {}", user_username, name)); + } + audit_log( &mut *tx, &authed, @@ -591,6 +616,17 @@ async fn add_user( Some([("user", user_username.as_str())].into()), ) .await?; + + log_group_permission_change( + &mut *tx, + &w_id, + &name, + &authed.username, + "add_member", + Some(&user_username), + ) + .await?; + tx.commit().await?; handle_deployment_metadata( @@ -851,6 +887,16 @@ async fn remove_user( ) .await?; + log_group_permission_change( + &mut *tx, + &w_id, + &name, + &authed.username, + "remove_member", + Some(&user_username), + ) + .await?; + tx.commit().await?; handle_deployment_metadata( @@ -981,3 +1027,26 @@ async fn overwrite_igroups() -> JsonResult { "This feature is only available in the enterprise version".to_string(), )) } + +pub async fn log_group_permission_change<'c, E: sqlx::Executor<'c, Database = Postgres>>( + db: E, + workspace_id: &str, + group_name: &str, + changed_by: &str, + change_type: &str, + member_affected: Option<&str>, +) -> Result<()> { + sqlx::query!( + "INSERT INTO group_permission_history + (workspace_id, group_name, changed_by, change_type, member_affected) + VALUES ($1, $2, $3, $4, $5)", + workspace_id, + group_name, + changed_by, + change_type, + member_affected + ) + .execute(db) + .await?; + Ok(()) +} diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index 0e69924e89..e121f54437 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -94,8 +94,10 @@ mod favorite; mod flow_conversations; pub mod flows; mod folders; +mod folder_history; mod granular_acls; mod groups; +mod group_history; #[cfg(feature = "private")] pub mod indexer_ee; mod indexer_oss; @@ -466,7 +468,9 @@ pub async fn run_server( flow_conversations::workspaced_service(), ) .nest("/folders", folders::workspaced_service()) + .nest("/folders_history", folder_history::workspaced_service()) .nest("/groups", groups::workspaced_service()) + .nest("/groups_history", group_history::workspaced_service()) .nest("/inputs", inputs::workspaced_service()) .nest("/job_metrics", job_metrics::workspaced_service()) .nest("/job_helpers", job_helpers_service) diff --git a/backend/windmill-common/src/agent_workers.rs b/backend/windmill-common/src/agent_workers.rs index 322ca1c4fb..21fa4249a3 100644 --- a/backend/windmill-common/src/agent_workers.rs +++ b/backend/windmill-common/src/agent_workers.rs @@ -28,7 +28,7 @@ lazy_static! { #[derive(Clone, Debug, Deserialize, Serialize)] pub struct AgentAuth { pub worker_group: String, - pub suffix: Option, +pub suffix: Option, pub tags: Vec, pub exp: Option, } diff --git a/frontend/src/lib/components/FolderEditor.svelte b/frontend/src/lib/components/FolderEditor.svelte index a2023240cf..a96eded3cd 100644 --- a/frontend/src/lib/components/FolderEditor.svelte +++ b/frontend/src/lib/components/FolderEditor.svelte @@ -20,6 +20,7 @@ import Select from './select/Select.svelte' import { safeSelectItems } from './select/utils.svelte' import TextInput from './text_input/TextInput.svelte' + import PermissionHistory from './PermissionHistory.svelte' interface Props { name: string @@ -90,6 +91,7 @@ role: getRole(x) } }) + reloadHistory++ } catch (e) { folderNotFound = true } @@ -144,6 +146,8 @@ }) } }) + + let reloadHistory = $state(0) @@ -351,7 +355,7 @@ {/if} - {#if can_write && (owner_name != 'u/' + $userStore?.username || $userStore?.is_admin)} + {#if (can_write && owner_name != 'u/' + $userStore?.username) || $userStore?.is_admin} + {/if} + {#if history.length === perPage} + + {/if} + + {/if} +