fix: add history directly viewable in folder/group viewer (#7365)

This commit is contained in:
Devdatta Talele
2025-12-15 23:31:07 +05:30
committed by GitHub
parent 61a3c81d5d
commit b3603d8720
22 changed files with 906 additions and 85 deletions
@@ -46,11 +46,11 @@
]
},
"nullable": [
true,
true,
true,
true,
true,
false,
false,
false,
false,
false,
true,
true
]
@@ -59,9 +59,7 @@
"failure",
"command",
"approval",
"preprocessor",
"schedule_handler_old",
"dynamic_skip"
"preprocessor"
]
}
}
@@ -15,7 +15,7 @@
]
},
"nullable": [
null
true
]
},
"hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55"
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -18,8 +18,8 @@
"Left": []
},
"nullable": [
true,
false
false,
true
]
},
"hash": "b3dbdfb50ee8118bdaed3164b210cb549a34b96554ae1872355b90304f5dcb76"
@@ -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"
}
@@ -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"
}
@@ -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;
@@ -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
));
+68
View File
@@ -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
@@ -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<chrono::Utc>,
pub change_type: String,
pub affected: Option<String>,
}
async fn get_folder_permission_history(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, name)): Path<(String, String)>,
Query(pagination): Query<Pagination>,
) -> JsonResult<Vec<FolderPermissionChange>> {
// 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))
}
+100 -7
View File
@@ -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<bool>>(&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(())
}
+124 -60
View File
@@ -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<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, path)): Path<(String, StripPath)>,
Json(GranularAcl { owner, write: _ }): Json<GranularAcl>,
Json(GranularAcl { owner, .. }): Json<GranularAcl>,
) -> Result<String> {
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())
+66
View File
@@ -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<chrono::Utc>,
pub change_type: String,
pub member_affected: Option<String>,
}
async fn get_group_permission_history(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, name)): Path<(String, String)>,
Query(pagination): Query<Pagination>,
) -> JsonResult<Vec<GroupPermissionChange>> {
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))
}
+70 -1
View File
@@ -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<String> {
"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(())
}
+4
View File
@@ -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)
+1 -1
View File
@@ -28,7 +28,7 @@ lazy_static! {
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct AgentAuth {
pub worker_group: String,
pub suffix: Option<String>,
pub suffix: Option<String>,
pub tags: Vec<String>,
pub exp: Option<usize>,
}
@@ -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)
</script>
<Drawer bind:this={newGroup}>
@@ -351,7 +355,7 @@
{/if}</td
>
<td class="flex items-center justify-end">
{#if can_write && (owner_name != 'u/' + $userStore?.username || $userStore?.is_admin)}
{#if (can_write && owner_name != 'u/' + $userStore?.username) || $userStore?.is_admin}
<Button
variant="subtle"
destructive
@@ -378,7 +382,7 @@
loadFolder()
}}
/>
{:else}
{:else if can_write && owner_name == 'u/' + $userStore?.username}
<span class="text-primary text-xs">cannot remove yourself</span>
{/if}</td
>
@@ -441,4 +445,20 @@
{/if}
</div>
</Label>
{#if reloadHistory > 0}
{#key reloadHistory}
<PermissionHistory
{name}
fetchHistory={async (workspace, folderName, page, perPage) => {
return await FolderService.getFolderPermissionHistory({
workspace,
name: folderName,
page,
perPage
})
}}
/>
{/key}
{/if}
</div>
@@ -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
@@ -81,6 +82,7 @@
}
})
summary = group.summary ?? ''
reloadHistory++
} catch (e) {
can_write = false
members = []
@@ -110,6 +112,7 @@
})
}
})
let reloadHistory = $state(0)
</script>
<div class="flex flex-col gap-6">
@@ -311,4 +314,20 @@
</div>
{/if}
</Label>
{#if reloadHistory > 0}
{#key reloadHistory}
<PermissionHistory
{name}
fetchHistory={async (workspace, groupName, page, perPage) => {
return await GroupService.getGroupPermissionHistory({
workspace,
name: groupName,
page,
perPage
})
}}
/>
{/key}
{/if}
</div>
@@ -0,0 +1,122 @@
<script lang="ts">
import { workspaceStore } from '$lib/stores'
import { untrack } from 'svelte'
import TableCustom from './TableCustom.svelte'
import Skeleton from './common/skeleton/Skeleton.svelte'
import Label from './Label.svelte'
interface PermissionChange {
id?: number
changed_by?: string
changed_at?: string
change_type?: string
affected?: string | null
member_affected?: string | null
}
interface Props {
name: string
fetchHistory: (
workspace: string,
name: string,
page: number,
perPage: number
) => Promise<PermissionChange[]>
}
let { name, fetchHistory }: Props = $props()
let history: PermissionChange[] | undefined = $state(undefined)
let loading = $state(false)
let page = $state(1)
let perPage = $state(50)
async function loadHistory() {
if (!$workspaceStore) return
loading = true
try {
history = await fetchHistory($workspaceStore, name, page, perPage)
} catch (e) {
console.error('Failed to load permission history:', e)
history = []
} finally {
loading = false
}
}
function formatDate(dateStr: string): string {
const date = new Date(dateStr)
return date.toLocaleString()
}
function formatChangeType(changeType: string): string {
return changeType
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
$effect.pre(() => {
if ($workspaceStore && name) {
untrack(() => {
loadHistory()
})
}
})
function removeUPrefix(username: string | undefined): string | undefined {
return username?.startsWith('u/') ? username.slice(2) : username
}
</script>
<Label label="History">
{#if loading || history === undefined}
<div class="flex flex-col gap-2">
{#each new Array(3) as _}
<Skeleton layout={[[4], 0.7]} />
{/each}
</div>
{:else if history.length === 0}
<p class="text-primary text-sm">No permission changes recorded yet</p>
{:else}
<TableCustom>
<tr slot="header-row">
<th>Changed By</th>
<th>Change Type</th>
<th>Affected</th>
<th>Date</th>
</tr>
{#snippet body()}
<tbody>
{#each history as change}
<tr>
<td>{change.changed_by ?? '-'}</td>
<td>{change.change_type ? formatChangeType(change.change_type) : '-'}</td>
<td>{change.affected ?? removeUPrefix(change.member_affected ?? '')}</td>
<td class="text-xs">{change.changed_at ? formatDate(change.changed_at) : '-'}</td>
</tr>
{/each}
</tbody>
{/snippet}
</TableCustom>
<div class="flex flex-row gap-2 justify-end">
{#if page > 1}
<button
class="text-primary text-xs"
onclick={() => {
page--
loadHistory()
}}>Previous</button
>
{/if}
{#if history.length === perPage}
<button
class="text-primary text-xs"
onclick={() => {
page++
loadHistory()
}}>Next</button
>
{/if}
</div>
{/if}
</Label>