Compare commits

...

3 Commits

Author SHA1 Message Date
Ruben Fiszel
1d304a36ea fix 2025-10-30 14:08:46 +00:00
claude[bot]
e9b83341e9 feat: add frontend components and OpenAPI spec for permission history
- Add OpenAPI endpoints for folder and group permission history
- Create reusable PermissionHistory component with Svelte 5
- Integrate history display into FolderEditor (for folder admins)
- Integrate history display into GroupEditor (for workspace admins)
- Fix ownership issues in folders.rs and granular_acls.rs
- Frontend validation passes (npm run check)

Note: SQLx query cache update required (cargo sqlx prepare)

Co-authored-by: Ruben Fiszel <rubenfiszel@users.noreply.github.com>
2025-10-30 12:13:19 +00:00
claude[bot]
f4820f1417 feat: add folder and group permission history tracking
- Add database tables for folder_permission_history and group_permission_history
- Track permission changes in folders (add/remove owner, update permissions)
- Track permission changes in groups (add/remove member, update summary)
- Track permission changes via granular ACL endpoints
- Add API endpoints to retrieve permission history
  - GET /api/w/:workspace/folders_history/get/:name (folder admins only)
  - GET /api/w/:workspace/groups_history/get/:name (workspace admins only)
- Simplified schema without before_state, after_state, or change_description fields

Co-authored-by: Ruben Fiszel <rubenfiszel@users.noreply.github.com>
2025-10-30 10:52:35 +00:00
16 changed files with 684 additions and 2 deletions

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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;

View File

@@ -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);

View File

@@ -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

View File

@@ -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 owner_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>> {
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))
}

View File

@@ -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(())
}

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,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 {

View File

@@ -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<chrono::Utc>,
pub change_type: String,
pub member_affected: Option<String>,
}
async fn get_group_permission_history(
authed: ApiAuthed,
Extension(db): Extension<DB>,
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?;
// 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))
}

View File

@@ -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<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(())
}

View File

@@ -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)

View File

@@ -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}
</div>
</Label>
{#if can_write}
<PermissionHistory
{name}
kind="folder"
fetchHistory={async (workspace, folderName, page, perPage) => {
return await FolderService.getFolderPermissionHistory({
workspace,
name: folderName,
page,
perPage
})
}}
/>
{/if}
</div>

View File

@@ -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 @@
</div>
{/if}
</Label>
{#if $userStore?.is_admin}
<PermissionHistory
{name}
kind="group"
fetchHistory={async (workspace, groupName, page, perPage) => {
return await GroupService.getGroupPermissionHistory({
workspace,
name: groupName,
page,
perPage
})
}}
/>
{/if}
</div>

View File

@@ -0,0 +1,99 @@
<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
owner_affected?: string | null
member_affected?: string | null
}
interface Props {
name: string
kind: 'folder' | 'group'
fetchHistory: (
workspace: string,
name: string,
page: number,
perPage: number
) => Promise<PermissionChange[]>
}
let { name, kind, fetchHistory }: Props = $props()
let history: PermissionChange[] | undefined = $state(undefined)
let loading = $state(false)
let page = $state(1)
let perPage = $state(10)
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()
})
}
})
</script>
<Label label="Permission 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>{kind === 'folder' ? 'Owner Affected' : 'Member 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.owner_affected ?? change.member_affected ?? '-'}</td>
<td class="text-xs">{change.changed_at ? formatDate(change.changed_at) : '-'}</td>
</tr>
{/each}
</tbody>
{/snippet}
</TableCustom>
{/if}
</Label>