diff --git a/backend/.sqlx/query-b0ca6573362ecc8d31b9ff35780897c03f9508ee061a6a5f47c2a4259f039b5f.json b/backend/.sqlx/query-b0ca6573362ecc8d31b9ff35780897c03f9508ee061a6a5f47c2a4259f039b5f.json new file mode 100644 index 0000000000000..56a122ef7f669 --- /dev/null +++ b/backend/.sqlx/query-b0ca6573362ecc8d31b9ff35780897c03f9508ee061a6a5f47c2a4259f039b5f.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 changed_at 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": "b0ca6573362ecc8d31b9ff35780897c03f9508ee061a6a5f47c2a4259f039b5f" +} diff --git a/backend/.sqlx/query-ba9f782bdd2349ff8ca87a3f6cd924c41f1ffa2fc05980023d7a99eadbf55a38.json b/backend/.sqlx/query-ba9f782bdd2349ff8ca87a3f6cd924c41f1ffa2fc05980023d7a99eadbf55a38.json new file mode 100644 index 0000000000000..425100f33a46f --- /dev/null +++ b/backend/.sqlx/query-ba9f782bdd2349ff8ca87a3f6cd924c41f1ffa2fc05980023d7a99eadbf55a38.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO folder_permission_history\n (workspace_id, folder_name, changed_by, change_type, owner_affected)\n VALUES ($1, $2, $3, $4, $5)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "ba9f782bdd2349ff8ca87a3f6cd924c41f1ffa2fc05980023d7a99eadbf55a38" +} diff --git a/backend/.sqlx/query-d0e01f07a46823fc9d6d81f4911537e904edd7c8d466d1338f82ecace0fe8436.json b/backend/.sqlx/query-d0e01f07a46823fc9d6d81f4911537e904edd7c8d466d1338f82ecace0fe8436.json new file mode 100644 index 0000000000000..d572493118f68 --- /dev/null +++ b/backend/.sqlx/query-d0e01f07a46823fc9d6d81f4911537e904edd7c8d466d1338f82ecace0fe8436.json @@ -0,0 +1,49 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, changed_by, changed_at, change_type, owner_affected\n FROM folder_permission_history\n WHERE workspace_id = $1 AND folder_name = $2\n ORDER BY changed_at 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": "owner_affected", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Int8", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + true + ] + }, + "hash": "d0e01f07a46823fc9d6d81f4911537e904edd7c8d466d1338f82ecace0fe8436" +} diff --git a/backend/.sqlx/query-f95358255e55d68dd453d173e23ab3cb1f1c2ba5b1cfc78f706b0b014f045477.json b/backend/.sqlx/query-f95358255e55d68dd453d173e23ab3cb1f1c2ba5b1cfc78f706b0b014f045477.json new file mode 100644 index 0000000000000..492bf20427e1f --- /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 0000000000000..9821b4d735ba6 --- /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 0000000000000..ef11e85a0463c --- /dev/null +++ b/backend/migrations/20251030104400_folder_group_permission_history.up.sql @@ -0,0 +1,31 @@ +-- 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, + owner_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, changed_at 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, changed_at DESC); diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 34e607e6649df..58eeb240fec33 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -11995,6 +11995,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 @@ -12258,6 +12292,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 + owner_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 0000000000000..7fdba9564a3b2 --- /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 owner_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> { + let mut tx = user_db.begin(&authed).await?; + + // Check if user is owner of the folder + crate::folders::require_is_owner(&authed, &name)?; + + let (per_page, offset) = paginate(pagination); + + let history = sqlx::query_as!( + FolderPermissionChange, + "SELECT id, changed_by, changed_at, change_type, owner_affected + FROM folder_permission_history + WHERE workspace_id = $1 AND folder_name = $2 + ORDER BY changed_at 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 9cf3c9af2b2f6..2afb63dec5235 100644 --- a/backend/windmill-api/src/folders.rs +++ b/backend/windmill-api/src/folders.rs @@ -329,6 +329,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| { @@ -415,6 +419,21 @@ async fn update_folder( None, ) .await?; + + // Log permission changes if owners or extra_perms were updated + let should_log = owners_changed || extra_perms_changed; + if should_log { + log_folder_permission_change( + &mut *tx, + &w_id, + &name, + &authed.username, + "update_permissions", + None, + ) + .await?; + } + tx.commit().await?; handle_deployment_metadata( @@ -672,6 +691,17 @@ async fn add_owner( Some([("owner", owner.as_str())].into()), ) .await?; + + log_folder_permission_change( + &mut *tx, + &w_id, + &name, + &authed.username, + "add_owner", + Some(&owner), + ) + .await?; + tx.commit().await?; webhook.send_message( @@ -725,6 +755,17 @@ async fn remove_owner( Some([("owner", owner.as_str())].into()), ) .await?; + + log_folder_permission_change( + &mut *tx, + &w_id, + &name, + &authed.username, + "remove_owner", + Some(&owner), + ) + .await?; + tx.commit().await?; webhook.send_message( @@ -734,3 +775,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, + owner_affected: Option<&str>, +) -> Result<()> { + sqlx::query!( + "INSERT INTO folder_permission_history + (workspace_id, folder_name, changed_by, change_type, owner_affected) + VALUES ($1, $2, $3, $4, $5)", + workspace_id, + folder_name, + changed_by, + change_type, + owner_affected + ) + .execute(db) + .await?; + Ok(()) +} diff --git a/backend/windmill-api/src/granular_acls.rs b/backend/windmill-api/src/granular_acls.rs index a9612d4a7ec86..27c3e704e2514 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,30 @@ async fn add_granular_acl( .await?; let _ = not_found_if_none(obj_o, &kind, &path)?; + + // Log permission changes for folders and groups + if kind == "folder" { + crate::folders::log_folder_permission_change( + &mut *tx, + &w_id, + path, + &authed.username, + "update_extra_perms", + Some(&owner), + ) + .await?; + } else if kind == "group_" { + crate::groups::log_group_permission_change( + &mut *tx, + &w_id, + path, + &authed.username, + "update_extra_perms", + Some(&owner), + ) + .await?; + } + tx.commit().await?; match kind { @@ -229,13 +253,37 @@ async fn remove_granular_acl( "UPDATE {kind} SET extra_perms = extra_perms - $1 WHERE {identifier} = $2 AND \ workspace_id = $3 RETURNING extra_perms" )) - .bind(owner) + .bind(&owner) .bind(path) .bind(&w_id) .fetch_optional(&mut *tx) .await?; let _ = not_found_if_none(obj_o, &kind, &path)?; + + // Log permission changes for folders and groups + if kind == "folder" { + crate::folders::log_folder_permission_change( + &mut *tx, + &w_id, + path, + &authed.username, + "remove_extra_perms", + Some(&owner), + ) + .await?; + } else if kind == "group_" { + crate::groups::log_group_permission_change( + &mut *tx, + &w_id, + path, + &authed.username, + "remove_extra_perms", + Some(&owner), + ) + .await?; + } + tx.commit().await?; match kind { diff --git a/backend/windmill-api/src/group_history.rs b/backend/windmill-api/src/group_history.rs new file mode 100644 index 0000000000000..7fb1e9c9992be --- /dev/null +++ b/backend/windmill-api/src/group_history.rs @@ -0,0 +1,73 @@ +/* + * 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, DB}; +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(db): Extension, + Extension(user_db): Extension, + Path((w_id, name)): Path<(String, String)>, + Query(pagination): Query, +) -> JsonResult> { + let mut tx = user_db.begin(&authed).await?; + + // Only workspace admins can view group permission history + if !authed.is_admin { + return Err(Error::NotAuthorized( + "Only workspace administrators can view group permission history".to_string(), + )); + } + + 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 changed_at 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 4325a7c9c5926..8044a264643e7 100644 --- a/backend/windmill-api/src/groups.rs +++ b/backend/windmill-api/src/groups.rs @@ -534,6 +534,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( @@ -583,6 +594,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( @@ -843,6 +865,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( @@ -973,3 +1005,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 802f6f03532b9..2c427b38f696a 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -90,8 +90,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; @@ -450,7 +452,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/frontend/src/lib/components/FolderEditor.svelte b/frontend/src/lib/components/FolderEditor.svelte index a2023240cfa6d..cc1414682d5ae 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 @@ -441,4 +442,19 @@ {/if} + + {#if can_write} + { + return await FolderService.getFolderPermissionHistory({ + workspace, + name: folderName, + page, + perPage + }) + }} + /> + {/if} diff --git a/frontend/src/lib/components/GroupEditor.svelte b/frontend/src/lib/components/GroupEditor.svelte index bdea0c4533031..1f5f7de254985 100644 --- a/frontend/src/lib/components/GroupEditor.svelte +++ b/frontend/src/lib/components/GroupEditor.svelte @@ -20,6 +20,7 @@ import { safeSelectItems } from './select/utils.svelte' import TextInput from './text_input/TextInput.svelte' import { Trash } from 'lucide-svelte' + import PermissionHistory from './PermissionHistory.svelte' interface Props { name: string @@ -311,4 +312,19 @@ {/if} + + {#if $userStore?.is_admin} + { + return await GroupService.getGroupPermissionHistory({ + workspace, + name: groupName, + page, + perPage + }) + }} + /> + {/if} diff --git a/frontend/src/lib/components/PermissionHistory.svelte b/frontend/src/lib/components/PermissionHistory.svelte new file mode 100644 index 0000000000000..c7cd811eee5f3 --- /dev/null +++ b/frontend/src/lib/components/PermissionHistory.svelte @@ -0,0 +1,99 @@ + + +