mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
fix: add history directly viewable in folder/group viewer (#7365)
This commit is contained in:
+5
-5
@@ -46,11 +46,11 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
|
||||
+1
-3
@@ -59,9 +59,7 @@
|
||||
"failure",
|
||||
"command",
|
||||
"approval",
|
||||
"preprocessor",
|
||||
"schedule_handler_old",
|
||||
"dynamic_skip"
|
||||
"preprocessor"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55"
|
||||
|
||||
+3
-3
@@ -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"
|
||||
}
|
||||
+49
@@ -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"
|
||||
}
|
||||
+49
@@ -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"
|
||||
}
|
||||
+2
-2
@@ -18,8 +18,8 @@
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "b3dbdfb50ee8118bdaed3164b210cb549a34b96554ae1872355b90304f5dcb76"
|
||||
|
||||
+18
@@ -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"
|
||||
}
|
||||
+18
@@ -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
|
||||
));
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user