feat: permission log

This commit is contained in:
Sumit Bhanushali 2024-09-30 14:54:32 +05:30
parent f2cf034821
commit 847dd62ec0
24 changed files with 514 additions and 34 deletions

View file

@ -35,3 +35,12 @@ class CustomDocPerm(Document):
def on_update(self):
frappe.clear_cache(doctype=self.parent)
def get_permission_log_options(self, event=None):
return {"for_doctype": "DocType", "for_document": self.parent}
def update_custom_docperm(docperm, values):
custom_docperm = frappe.get_doc("Custom DocPerm", docperm)
custom_docperm.update(values)
custom_docperm.save(ignore_permissions=True)

View file

@ -25,6 +25,11 @@ class CustomRole(Document):
if self.report and not self.ref_doctype:
self.ref_doctype = frappe.db.get_value("Report", self.report, "ref_doctype")
def get_permission_log_options(self, event=None):
if self.report:
return {"for_doctype": "Report", "for_document": self.report, "fields": ["roles"]}
return {"for_doctype": "Page", "for_document": self.page, "fields": ["roles"]}
def get_custom_allowed_roles(field, name):
allowed_roles = []

View file

@ -505,6 +505,14 @@ class DocType(Document):
if d.unique:
d.search_index = 0
def get_permission_log_options(self, event=None):
if self.custom and event != "after_delete":
return {
"fields": ("permissions", {"fields": ("fieldname", "ignore_user_permissions", "permlevel")})
}
self._no_perm_log = True
def on_update(self):
"""Update database schema, make controller templates if `custom` is not set and clear cache."""

View file

@ -22,3 +22,6 @@ class ModuleProfile(Document):
from frappe.utils.modules import get_modules_from_all_apps
self.set_onload("all_modules", sorted(m.get("module_name") for m in get_modules_from_all_apps()))
def get_permission_log_options(self, event=None):
return {"fields": ["block_modules"]}

View file

@ -66,6 +66,9 @@ class Page(Document):
if frappe.session.user != "Administrator" and not self.flags.ignore_permissions:
frappe.throw(_("Only Administrator can edit"))
def get_permission_log_options(self, event=None):
return {"fields": ["roles"]}
# export
def on_update(self):
"""
@ -97,8 +100,8 @@ class Page(Document):
}}"""
)
def as_dict(self, no_nulls=False):
d = super().as_dict(no_nulls=no_nulls)
def as_dict(self, **kwargs):
d = super().as_dict(**kwargs)
for key in ("script", "style", "content"):
d[key] = self.get(key)
return d

View file

@ -0,0 +1,75 @@
// Copyright (c) 2022, Frappe Technologies and contributors
// For license information, please see license.txt
frappe.ui.form.on("Permission Log", {
refresh: function (frm) {
frm.events.render_changed_values(frm);
},
render_changed_values: function (frm) {
let wrapper = $(frm.fields_dict["changed_values"].wrapper).empty();
const changes = JSON.parse(frm.doc.changes);
let changes_table = $(`<table class="table table-bordered">
<thead>
<tr>
<td>${__("Field")}</td>
<td>${__("From")}</td>
<td>${__("To")}</td>
</tr>
</thead>
<tbody class="main-body"></tbody>
</table>`);
Object.entries(changes["from"]).forEach(([key, value]) => {
if (Array.isArray(value || changes["to"][key])) {
changes_table
.find(".main-body")
.append(frm.events.get_child_changes(key, value, changes["to"][key]));
} else {
changes_table.find("tbody").append(
$(`<tr>
<td>${frappe.model.unscrub(key)}</td>
<td style="word-break: break-word" class="diff-remove">${changes["from"][key]}</td>
<td style="word-break: break-word" class="diff-add">${changes["to"][key]}</td>
</tr>`)
);
}
});
wrapper.append(changes_table);
},
get_child_changes: function (field_key, from, to) {
let child_main = $(`<tr>
<td>${frappe.model.unscrub(field_key)}</td>
<td class="from"></td>
<td class="to"></td>
</tr>`);
[from, to].forEach((val, index) => {
if (!val) return;
let for_value = index > 0 ? "to" : "frfromom";
let child_table = $(`<table class="table-bordered small" style="margin-bottom: 5px">
<tbody></tbody>
</table>`);
val.forEach((child_row) => {
for (const [key, value] of Object.entries(child_row)) {
child_table.find("tbody").append(
$(
`<tr>
<td style="word-break: break-word">${frappe.model.unscrub(key)}</td>
<td style="word-break: break-word">${value}</td>
</tr>`
)
);
}
});
child_main.find(`.${for_value}`).append(child_table);
});
return child_main;
},
});

View file

@ -0,0 +1,156 @@
{
"actions": [],
"autoname": "hash",
"creation": "2022-11-23 14:13:55.437321",
"default_view": "List",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"changed_by",
"column_break_gvuy",
"changed_at",
"status",
"section_break_ttv7",
"for_doctype",
"column_break_acow",
"for_document",
"section_break_6gd5",
"reference_type",
"column_break_8nk0",
"reference",
"section_break_mt9x",
"changes",
"changed_values"
],
"fields": [
{
"fieldname": "changed_by",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Changed by",
"options": "User",
"read_only": 1
},
{
"fieldname": "changed_at",
"fieldtype": "Datetime",
"is_virtual": 1,
"label": "Changed at",
"read_only": 1
},
{
"fieldname": "changes",
"fieldtype": "Text",
"hidden": 1
},
{
"fieldname": "changed_values",
"fieldtype": "HTML",
"label": "Changes"
},
{
"fieldname": "column_break_gvuy",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_ttv7",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_acow",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_mt9x",
"fieldtype": "Section Break"
},
{
"collapsible": 1,
"fieldname": "section_break_6gd5",
"fieldtype": "Section Break",
"label": "More Info"
},
{
"fieldname": "column_break_8nk0",
"fieldtype": "Column Break"
},
{
"fieldname": "for_doctype",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "For DocType",
"options": "DocType",
"read_only": 1,
"reqd": 1
},
{
"fieldname": "for_document",
"fieldtype": "Dynamic Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "For Document",
"options": "for_doctype",
"read_only": 1,
"reqd": 1
},
{
"fieldname": "reference_type",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Reference Type",
"options": "DocType",
"read_only": 1
},
{
"fieldname": "reference",
"fieldtype": "Dynamic Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Reference",
"options": "reference_type",
"read_only": 1
},
{
"fieldname": "status",
"fieldtype": "Select",
"hidden": 1,
"label": "Status",
"options": "Updated\nRemoved\nAdded"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-09-25 17:23:31.599897",
"modified_by": "Administrator",
"module": "Core",
"name": "Permission Log",
"naming_rule": "Random",
"owner": "Administrator",
"permissions": [
{
"read": 1,
"report": 1,
"role": "System Manager"
}
],
"sort_field": "modified",
"sort_order": "DESC",
"states": [
{
"color": "Yellow",
"title": "Updated"
},
{
"color": "Red",
"title": "Removed"
},
{
"color": "Green",
"title": "Added"
}
],
"title_field": "changed_by"
}

View file

@ -0,0 +1,150 @@
# Copyright (c) 2022, Frappe Technologies and contributors
# For license information, please see license.txt
from typing import Optional
import frappe
from frappe.model.document import Document
class PermissionLog(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from frappe.types import DF
changed_at: DF.Datetime | None
changed_by: DF.Link | None
changes: DF.Text | None
for_doctype: DF.Link
for_document: DF.DynamicLink
reference: DF.DynamicLink | None
reference_type: DF.Link | None
status: DF.Literal["Updated", "Removed", "Added"]
# end: auto-generated types
@property
def changed_at(self):
return self.creation
def make_perm_log(doc, method=None):
if not hasattr(doc, "get_permission_log_options"):
return
params = doc.get_permission_log_options(method) or {}
if not getattr(doc, "_no_perm_log", False):
insert_perm_log(doc, doc.get_doc_before_save(), **params)
def insert_perm_log(
doc: Document,
doc_before_save: Document = None,
for_doctype: Optional["str"] = None,
for_document: Optional["str"] = None,
fields: Optional["list | tuple"] = None,
):
if frappe.flags.in_install or frappe.flags.in_migrate:
# no need to log changes when migrating or installing app/site
return
current, previous = get_changes(doc, doc_before_save, fields)
if not previous and not current:
return
status = "Updated" if doc_before_save else ("Added" if doc.flags.in_insert else "Removed")
frappe.get_doc(
{
"doctype": "Permission Log",
"owner": frappe.session.user,
"changed_by": frappe.session.user,
"reference_type": for_doctype and doc.doctype,
"reference": for_document and doc.name,
"for_doctype": for_doctype or doc.doctype,
"for_document": for_document or doc.name,
"status": status,
"changes": frappe.as_json({"from": previous, "to": current}, indent=0),
}
).db_insert()
def get_changes(doc: Document, doc_before_save=None, fields=None):
current_changes = get_filtered_changes(
doc.as_dict(
no_default_fields=True,
no_child_table_fields=True,
no_private_properties=True,
),
fields,
)
if not doc_before_save:
empty_changes = dict.fromkeys(current_changes, "")
return (current_changes, empty_changes) if doc.flags.in_insert else (empty_changes, current_changes)
previous_changes = get_filtered_changes(
doc_before_save.as_dict(
no_default_fields=True,
no_child_table_fields=True,
no_private_properties=True,
),
fields,
)
return get_changes_diff(current_changes, previous_changes)
def get_changes_diff(current_changes, previous_changes):
# TODO: track, added, removed and changed rows in child tables
current_values = {}
previous_values = {}
for k, current_val in current_changes.items():
if isinstance(current_val, list):
# for child table docs
current = {frozenset(row.items()) for row in current_val}
previous = {frozenset(row.items()) for row in previous_changes[k]}
if not current.symmetric_difference(previous):
continue
previous_values[k] = [dict(i) for i in previous - current]
current_val = [dict(i) for i in current - previous]
elif previous_changes.get(k, None) == current_val:
continue
previous_values[k] = previous_values[k] if k in previous_values else previous_changes[k]
current_values[k] = current_val
return current_values, previous_values
def get_filtered_changes(changes, filters=None):
def filter_child_docs(child_docs, filter_keys):
changes = []
for child in child_docs:
temp = {}
for key in filter_keys:
temp[key] = child[key]
changes.append(temp)
return changes
if not filters:
return changes
filtered_changes = {}
for f in filters:
if isinstance(f, dict):
# filtered child docs
for field, cf in f.items():
filtered_changes[field] = filter_child_docs(changes.get(field, []), cf)
else:
filtered_changes[f] = changes.get(f, None)
return filtered_changes

View file

@ -0,0 +1,9 @@
frappe.listview_settings["Permission Log"] = {
hide_name_column: true,
onload(listview) {
if (listview.list_view_settings) {
listview.list_view_settings.disable_comment_count = 1;
}
},
};

View file

@ -0,0 +1,9 @@
# Copyright (c) 2022, Frappe Technologies and Contributors
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
class TestPermissionLog(FrappeTestCase):
pass

View file

@ -95,6 +95,9 @@ class Report(Document):
frappe.throw(_("You are not allowed to delete Standard Report"))
delete_custom_role("report", self.name)
def get_permission_log_options(self, event=None):
return {"fields": ["roles"]}
def get_columns(self):
return [d.as_dict(no_default_fields=True, no_child_table_fields=True) for d in self.columns]

View file

@ -5,7 +5,6 @@ import frappe
from frappe.core.doctype.report.report import is_prepared_report_enabled
from frappe.model.document import Document
from frappe.permissions import ALL_USER_ROLE
from frappe.utils import cint
class RolePermissionforPageandReport(Document):
@ -67,16 +66,17 @@ class RolePermissionforPageandReport(Document):
def update_custom_roles(self):
args = self.get_args()
roles = self.get_roles()
name = frappe.db.get_value("Custom Role", args, "name")
args.update({"doctype": "Custom Role", "roles": self.get_roles()})
args.update({"doctype": "Custom Role", "roles": roles})
if self.report:
args.update({"ref_doctype": frappe.db.get_value("Report", self.report, "ref_doctype")})
if name:
custom_role = frappe.get_doc("Custom Role", name)
custom_role.set("roles", self.get_roles())
custom_role.set("roles", roles)
custom_role.save()
else:
frappe.get_doc(args).insert()

View file

@ -39,3 +39,6 @@ class RoleProfile(Document):
for user in users:
user = frappe.get_doc("User", user)
user.save() # resaving syncs roles
def get_permission_log_options(self, event=None):
return {"fields": ["roles"]}

View file

@ -778,6 +778,9 @@ class User(Document):
if not self.time_zone:
self.time_zone = get_system_timezone()
def get_permission_log_options(self, event=None):
return {"fields": ("role_profile_name", "roles", "module_profile", "block_modules")}
def check_roles_added(self):
if self.user_type != "System User" or self.roles or not self.is_new():
return

View file

@ -76,6 +76,9 @@ class UserPermission(Document):
ref_link = frappe.get_desk_link(self.doctype, overlap_exists[0].name)
frappe.throw(_("{0} has already assigned default value for {1}.").format(ref_link, self.allow))
def get_permission_log_options(self, event=None):
pass
def send_user_permissions(bootinfo):
bootinfo.user["user_permissions"] = get_user_permissions()

View file

@ -3,6 +3,7 @@
import frappe
from frappe import _
from frappe.core.doctype.custom_docperm.custom_docperm import update_custom_docperm
from frappe.model.document import Document
from frappe.permissions import add_permission, add_user_permission
from frappe.utils import get_link_to_form
@ -142,7 +143,7 @@ class UserType(Document):
docperm = add_role_permissions(row.document_type, self.role)
values = {perm: row.get(perm, default=0) for perm in perms}
frappe.db.set_value("Custom DocPerm", docperm, values)
update_custom_docperm(docperm, values)
def add_select_perm_doctypes(self):
if frappe.flags.ignore_select_perm:
@ -176,13 +177,11 @@ class UserType(Document):
for doctype in ["select_doctypes", "custom_select_doctypes"]:
for row in self.get(doctype):
docperm = add_role_permissions(row.document_type, self.role)
frappe.db.set_value(
"Custom DocPerm", docperm, {"select": 1, "read": 0, "create": 0, "write": 0}
)
update_custom_docperm(docperm, {"select": 1, "read": 0, "create": 0, "write": 0})
def add_role_permissions_for_file(self):
docperm = add_role_permissions("File", self.role)
frappe.db.set_value("Custom DocPerm", docperm, {"read": 1, "create": 1, "write": 1})
update_custom_docperm(docperm, {"read": 1, "create": 1, "write": 1})
def remove_permission_for_deleted_doctypes(self):
doctypes = [d.document_type for d in self.user_doctypes]
@ -340,4 +339,6 @@ def apply_permissions_for_non_standard_user_type(doc, method=None):
user_doc.update_children()
add_user_permission(doc.doctype, doc.name, doc.get(data[1]))
else:
frappe.db.set_value("User Permission", perm_data[0], "user", doc.get(data[1]))
user_perm = frappe.get_doc("User Permission", perm_data[0])
user_perm.user = doc.get(data[1])
user_perm.save(ignore_permissions=True)

View file

@ -146,10 +146,11 @@ def remove(doctype, role, permlevel, if_owner=0):
frappe.only_for("System Manager")
setup_custom_perms(doctype)
frappe.db.delete(
"Custom DocPerm",
{"parent": doctype, "role": role, "permlevel": permlevel, "if_owner": if_owner},
custom_docperms = frappe.db.get_values(
"Custom DocPerm", {"parent": doctype, "role": role, "permlevel": permlevel, "if_owner": if_owner}
)
for name in custom_docperms:
frappe.delete_doc("Custom DocPerm", name, ignore_permissions=True)
if not frappe.get_all("Custom DocPerm", {"parent": doctype}):
frappe.throw(_("There must be atleast one permission rule."), title=_("Cannot Remove"))

View file

@ -5,6 +5,7 @@ import json
import frappe
from frappe import _
from frappe.custom.doctype.property_setter.property_setter import delete_property_setter
from frappe.model import core_doctypes_list
from frappe.model.docfield import supports_translation
from frappe.model.document import Document
@ -221,7 +222,7 @@ class CustomField(Document):
)
# delete property setter entries
frappe.db.delete("Property Setter", {"doc_type": self.dt, "field_name": self.fieldname})
delete_property_setter(self.dt, field_name=self.fieldname)
# update doctype layouts
doctype_layouts = frappe.get_all("DocType Layout", filters={"document_type": self.dt}, pluck="name")
@ -248,6 +249,21 @@ class CustomField(Document):
if self.fieldname == self.insert_after:
frappe.throw(_("Insert After cannot be set as {0}").format(meta.get_label(self.insert_after)))
def get_permission_log_options(self, event=None):
if event != "after_delete" and self.fieldtype not in (
"Section Break",
"Column Break",
"Tab Break",
"Fold",
):
return {
"fields": ("ignore_user_permissions", "permlevel"),
"for_doctype": "DocType",
"for_document": self.dt,
}
self._no_perm_log = True
@frappe.whitelist()
def get_fields_label(doctype=None):

View file

@ -440,7 +440,7 @@ class CustomizeForm(Document):
property_name, json.dumps([d.name for d in self.get(fieldname)]), "Small Text"
)
else:
frappe.db.delete("Property Setter", dict(property=property_name, doc_type=self.doc_type))
delete_property_setter(self.doc_type, property=property_name)
def clear_removed_items(self, doctype, items):
"""

View file

@ -59,6 +59,16 @@ class PropertySetter(Document):
validate_fields_for_doctype(self.doc_type)
def get_permission_log_options(self, event=None):
if self.property in ("ignore_user_permissions", "permlevel"):
return {
"for_doctype": "DocType",
"for_document": self.doc_type,
"fields": ("value", "property", "field_name"),
}
self._no_perm_log = True
def make_property_setter(
doctype,
@ -87,12 +97,17 @@ def make_property_setter(
return property_setter
def delete_property_setter(doc_type, property, field_name=None, row_name=None):
def delete_property_setter(doc_type, property=None, field_name=None, row_name=None):
"""delete other property setters on this, if this is new"""
filters = dict(doc_type=doc_type, property=property)
filters = {"doc_type": doc_type}
if property:
filters["property"] = property
if field_name:
filters["field_name"] = field_name
if row_name:
filters["row_name"] = row_name
frappe.db.delete("Property Setter", filters)
property_setters = frappe.db.get_values("Property Setter", filters)
for ps in property_setters:
frappe.get_doc("Property Setter", ps).delete(ignore_permissions=True)

View file

@ -157,6 +157,7 @@ doc_events = {
"frappe.automation.doctype.assignment_rule.assignment_rule.apply",
"frappe.automation.doctype.assignment_rule.assignment_rule.update_due_date",
"frappe.core.doctype.user_type.user_type.apply_permissions_for_non_standard_user_type",
"frappe.core.doctype.permission_log.permission_log.make_perm_log",
],
"after_rename": "frappe.desk.notifications.clear_doctype_notifications",
"on_cancel": [
@ -178,6 +179,7 @@ doc_events = {
"frappe.social.doctype.energy_point_rule.energy_point_rule.process_energy_points",
"frappe.automation.doctype.milestone_tracker.milestone_tracker.evaluate_milestone",
],
"after_delete": ["frappe.core.doctype.permission_log.permission_log.make_perm_log"],
},
"Event": {
"after_insert": "frappe.integrations.doctype.google_calendar.google_calendar.insert_event_in_google_calendar",
@ -422,6 +424,7 @@ ignore_links_on_delete = [
"Workspace",
"Route History",
"Access Log",
"Permission Log",
]
# Request Hooks

View file

@ -490,6 +490,7 @@ class BaseDocument:
no_default_fields=False,
convert_dates_to_str=False,
no_child_table_fields=False,
no_private_properties=False,
) -> dict:
doc = self.get_valid_dict(convert_dates_to_str=convert_dates_to_str, ignore_nulls=no_nulls)
doc["doctype"] = self.doctype
@ -502,6 +503,7 @@ class BaseDocument:
no_nulls=no_nulls,
no_default_fields=no_default_fields,
no_child_table_fields=no_child_table_fields,
no_private_properties=no_private_properties,
)
for d in children
]
@ -516,16 +518,17 @@ class BaseDocument:
if key in doc:
del doc[key]
for key in (
"_user_tags",
"__islocal",
"__onload",
"_liked_by",
"__run_link_triggers",
"__unsaved",
):
if value := getattr(self, key, None):
doc[key] = value
if not no_private_properties:
for key in (
"_user_tags",
"__islocal",
"__onload",
"_liked_by",
"__run_link_triggers",
"__unsaved",
):
if value := getattr(self, key, None):
doc[key] = value
return doc

View file

@ -614,15 +614,16 @@ def update_permission_property(
if_owner=0,
):
"""Update a property in Custom Perm"""
from frappe.core.doctype.custom_docperm.custom_docperm import update_custom_docperm
from frappe.core.doctype.doctype.doctype import validate_permissions_for_doctype
out = setup_custom_perms(doctype)
name = frappe.db.get_value(
"Custom DocPerm", dict(parent=doctype, role=role, permlevel=permlevel, if_owner=if_owner)
custom_docperm = frappe.db.get_value(
"Custom DocPerm", dict(parent=doctype, role=role, permlevel=permlevel)
)
table = DocType("Custom DocPerm")
frappe.qb.update(table).set(ptype, value).where(table.name == name).run()
if custom_docperm:
update_custom_docperm(custom_docperm, {ptype: value})
if validate:
validate_permissions_for_doctype(doctype)
@ -690,7 +691,8 @@ def reset_perms(doctype):
from frappe.desk.notifications import delete_notification_count_for
delete_notification_count_for(doctype)
frappe.db.delete("Custom DocPerm", {"parent": doctype})
for custom_docperm in frappe.get_all("Custom DocPerm", filters={"parent": doctype}, pluck="name"):
frappe.delete_doc("Custom DocPerm", custom_docperm, ignore_permissions=True)
def get_linked_doctypes(dt: str) -> list: