access control: hide Quality and Subcontracting modules
Adds jey_theme.access_control with a single source of truth (BLOCKED_MODULES, BLOCKED_DOCTYPES_EXTRA) and three Frappe hooks: - before_request: HTTP-level block on /api/resource/<Doctype>, /app/<slug> and any whitelisted method passing `doctype` in form_dict. Runs before the Administrator bypass in frappe.permissions, so it blocks every user. - extend_bootinfo: strips blocked modules/doctypes/workspaces from bootinfo (workspaces.pages, module_wise_workspaces, app_data, desktop_icons, user.can_read/...) so the client router never registers routes for them and the sidebar/switcher hide them. - has_permission wildcard: per-doc safety net for non-Administrator users. shared.css gets desktop-icon[data-id] fallbacks for Quality/Subcontracting. Reversal: clear BLOCKED_MODULES in access_control.py and run `bench --site <site> clear-cache && bench restart`. No DB writes are made. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a217ea104a
commit
96099513a1
|
|
@ -0,0 +1,240 @@
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
|
import frappe
|
||||||
|
from frappe import _
|
||||||
|
|
||||||
|
BLOCKED_MODULES: frozenset[str] = frozenset({
|
||||||
|
"Quality Management",
|
||||||
|
"Subcontracting",
|
||||||
|
})
|
||||||
|
|
||||||
|
BLOCKED_DOCTYPES_EXTRA: frozenset[str] = frozenset({
|
||||||
|
"Quality Inspection",
|
||||||
|
"Quality Inspection Template",
|
||||||
|
"Quality Inspection Parameter",
|
||||||
|
"Quality Inspection Reading",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _slugify(name: str) -> str:
|
||||||
|
return name.lower().replace(" ", "-")
|
||||||
|
|
||||||
|
|
||||||
|
def get_blocked_doctypes() -> frozenset[str]:
|
||||||
|
cached = getattr(frappe.local, "_jey_blocked_doctypes", None)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
doctypes = set(BLOCKED_DOCTYPES_EXTRA)
|
||||||
|
if BLOCKED_MODULES:
|
||||||
|
try:
|
||||||
|
rows = frappe.get_all(
|
||||||
|
"DocType",
|
||||||
|
filters={"module": ("in", list(BLOCKED_MODULES))},
|
||||||
|
pluck="name",
|
||||||
|
)
|
||||||
|
doctypes.update(rows)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
result = frozenset(doctypes)
|
||||||
|
frappe.local._jey_blocked_doctypes = result
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_blocked_workspace_names() -> frozenset[str]:
|
||||||
|
cached = getattr(frappe.local, "_jey_blocked_workspaces", None)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
names: set[str] = set()
|
||||||
|
try:
|
||||||
|
rows = frappe.get_all(
|
||||||
|
"Workspace",
|
||||||
|
filters={"module": ("in", list(BLOCKED_MODULES))},
|
||||||
|
pluck="name",
|
||||||
|
)
|
||||||
|
names.update(rows)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
result = frozenset(names)
|
||||||
|
frappe.local._jey_blocked_workspaces = result
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_blocked_slugs() -> frozenset[str]:
|
||||||
|
cached = getattr(frappe.local, "_jey_blocked_slugs", None)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
slugs = {_slugify(dt) for dt in get_blocked_doctypes()}
|
||||||
|
for w in get_blocked_workspace_names():
|
||||||
|
slugs.add(_slugify(w))
|
||||||
|
result = frozenset(slugs)
|
||||||
|
frappe.local._jey_blocked_slugs = result
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def is_blocked_doctype(doctype: str) -> bool:
|
||||||
|
return doctype in get_blocked_doctypes()
|
||||||
|
|
||||||
|
|
||||||
|
def _deny():
|
||||||
|
frappe.throw(_("Not Permitted"), frappe.PermissionError)
|
||||||
|
|
||||||
|
|
||||||
|
def before_request():
|
||||||
|
"""HTTP-level block. Works even for Administrator (who bypasses has_permission).
|
||||||
|
Covers /api/resource/<Doctype>, /app/<slug>, and any whitelisted method that
|
||||||
|
passes `doctype` in form_dict."""
|
||||||
|
req = getattr(frappe.local, "request", None)
|
||||||
|
if req is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
path = getattr(req, "path", "") or ""
|
||||||
|
|
||||||
|
if path.startswith("/api/resource/"):
|
||||||
|
remainder = path[len("/api/resource/"):]
|
||||||
|
slug = remainder.split("/", 1)[0]
|
||||||
|
doctype = unquote(slug)
|
||||||
|
if is_blocked_doctype(doctype):
|
||||||
|
_deny()
|
||||||
|
|
||||||
|
if path.startswith("/app/"):
|
||||||
|
remainder = path[len("/app/"):]
|
||||||
|
first_segment = remainder.split("/", 1)[0].split("?", 1)[0].lower()
|
||||||
|
if first_segment and first_segment in get_blocked_slugs():
|
||||||
|
_deny()
|
||||||
|
|
||||||
|
form_dict = getattr(frappe.local, "form_dict", None)
|
||||||
|
if form_dict:
|
||||||
|
for key in ("doctype", "dt", "parent_doctype"):
|
||||||
|
value = form_dict.get(key)
|
||||||
|
if isinstance(value, str) and is_blocked_doctype(value):
|
||||||
|
_deny()
|
||||||
|
|
||||||
|
|
||||||
|
def has_permission(doc=None, ptype=None, user=None, debug=False):
|
||||||
|
"""Per-doc hook. Applies to non-Administrator users that might otherwise have role access."""
|
||||||
|
doctype = None
|
||||||
|
if doc is not None:
|
||||||
|
doctype = getattr(doc, "doctype", None)
|
||||||
|
if doctype is None and isinstance(doc, str):
|
||||||
|
doctype = doc
|
||||||
|
if not doctype:
|
||||||
|
return None
|
||||||
|
if is_blocked_doctype(doctype):
|
||||||
|
return False
|
||||||
|
if doctype == "Workspace":
|
||||||
|
module = getattr(doc, "module", None)
|
||||||
|
if module and module in BLOCKED_MODULES:
|
||||||
|
return False
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_list(container, key, predicate):
|
||||||
|
if isinstance(container, dict):
|
||||||
|
value = container.get(key)
|
||||||
|
else:
|
||||||
|
value = getattr(container, key, None)
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return
|
||||||
|
filtered = [item for item in value if predicate(item)]
|
||||||
|
if isinstance(container, dict):
|
||||||
|
container[key] = filtered
|
||||||
|
else:
|
||||||
|
setattr(container, key, filtered)
|
||||||
|
|
||||||
|
|
||||||
|
def _module_of(item):
|
||||||
|
if isinstance(item, dict):
|
||||||
|
return item.get("module") or item.get("module_name")
|
||||||
|
return getattr(item, "module", None) or getattr(item, "module_name", None)
|
||||||
|
|
||||||
|
|
||||||
|
def extend_bootinfo(bootinfo):
|
||||||
|
"""Strip blocked modules/doctypes from bootinfo so client router
|
||||||
|
never creates routes for them and the sidebar/switcher hide them."""
|
||||||
|
blocked_doctypes = get_blocked_doctypes()
|
||||||
|
blocked_modules = BLOCKED_MODULES
|
||||||
|
|
||||||
|
allowed_modules = getattr(bootinfo, "allowed_modules", None)
|
||||||
|
if isinstance(allowed_modules, list):
|
||||||
|
bootinfo.allowed_modules = [
|
||||||
|
m for m in allowed_modules if _module_of(m) not in blocked_modules
|
||||||
|
]
|
||||||
|
|
||||||
|
modules = getattr(bootinfo, "modules", None)
|
||||||
|
if isinstance(modules, dict):
|
||||||
|
for mod_name in list(modules.keys()):
|
||||||
|
if mod_name in blocked_modules:
|
||||||
|
del modules[mod_name]
|
||||||
|
|
||||||
|
user = getattr(bootinfo, "user", None)
|
||||||
|
if user is not None:
|
||||||
|
not_blocked = lambda dt: dt not in blocked_doctypes
|
||||||
|
for key in (
|
||||||
|
"can_read", "can_write", "can_create", "can_cancel",
|
||||||
|
"can_delete", "can_get_report", "can_search", "can_export",
|
||||||
|
"can_print", "can_email", "all_read", "all_reports",
|
||||||
|
"can_select", "can_submit",
|
||||||
|
):
|
||||||
|
_filter_list(user, key, not_blocked)
|
||||||
|
_filter_list(user, "allow_modules", lambda m: m not in blocked_modules)
|
||||||
|
|
||||||
|
# bootinfo.workspaces = {"pages": [...], "blocks": [...], ...}
|
||||||
|
workspaces = getattr(bootinfo, "workspaces", None)
|
||||||
|
if isinstance(workspaces, dict):
|
||||||
|
for key in ("pages", "private_pages"):
|
||||||
|
pages = workspaces.get(key)
|
||||||
|
if isinstance(pages, list):
|
||||||
|
workspaces[key] = [p for p in pages if _module_of(p) not in blocked_modules]
|
||||||
|
|
||||||
|
# Sidebar items keyed by section title
|
||||||
|
sidebar_items = getattr(bootinfo, "workspace_sidebar_item", None)
|
||||||
|
if isinstance(sidebar_items, dict):
|
||||||
|
for section_key in list(sidebar_items.keys()):
|
||||||
|
section = sidebar_items[section_key]
|
||||||
|
if isinstance(section, dict) and isinstance(section.get("items"), list):
|
||||||
|
section["items"] = [
|
||||||
|
it for it in section["items"] if _module_of(it) not in blocked_modules
|
||||||
|
]
|
||||||
|
|
||||||
|
# Module-keyed workspace dict
|
||||||
|
mww = getattr(bootinfo, "module_wise_workspaces", None)
|
||||||
|
if isinstance(mww, dict):
|
||||||
|
for mod_name in list(mww.keys()):
|
||||||
|
if mod_name in blocked_modules:
|
||||||
|
del mww[mod_name]
|
||||||
|
|
||||||
|
# Per-app data (workspaces & modules lists)
|
||||||
|
app_data = getattr(bootinfo, "app_data", None)
|
||||||
|
if isinstance(app_data, list):
|
||||||
|
for app in app_data:
|
||||||
|
if not isinstance(app, dict):
|
||||||
|
continue
|
||||||
|
ws = app.get("workspaces")
|
||||||
|
if isinstance(ws, list):
|
||||||
|
# Remove workspace names that belong to blocked modules.
|
||||||
|
# We compare by name since app_data workspaces are just names.
|
||||||
|
app["workspaces"] = [w for w in ws if _slugify(w) not in get_blocked_slugs()]
|
||||||
|
mods = app.get("modules")
|
||||||
|
if isinstance(mods, list):
|
||||||
|
app["modules"] = [m for m in mods if m not in blocked_modules]
|
||||||
|
|
||||||
|
for key in ("allowed_pages", "allowed_workspaces", "sidebar_pages"):
|
||||||
|
items = getattr(bootinfo, key, None)
|
||||||
|
if isinstance(items, list):
|
||||||
|
setattr(bootinfo, key, [
|
||||||
|
it for it in items if _module_of(it) not in blocked_modules
|
||||||
|
])
|
||||||
|
|
||||||
|
# Desktop Icon records (used by legacy /desk page). Filter by label/link_to/name.
|
||||||
|
icons = getattr(bootinfo, "desktop_icons", None)
|
||||||
|
if isinstance(icons, list):
|
||||||
|
blocked_ws = get_blocked_workspace_names()
|
||||||
|
def _icon_blocked(i):
|
||||||
|
if _module_of(i) in blocked_modules:
|
||||||
|
return True
|
||||||
|
for key in ("label", "link_to", "name", "module_name"):
|
||||||
|
val = i.get(key) if isinstance(i, dict) else getattr(i, key, None)
|
||||||
|
if val and val in blocked_ws:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
bootinfo.desktop_icons = [i for i in icons if not _icon_blocked(i)]
|
||||||
|
|
@ -18,6 +18,16 @@ app_include_css = [
|
||||||
|
|
||||||
app_include_js = "/assets/jey_theme/js/jey_theme.js"
|
app_include_js = "/assets/jey_theme/js/jey_theme.js"
|
||||||
|
|
||||||
|
# Access control: скрытие модулей Quality Management / Subcontracting.
|
||||||
|
# Список заблокированных модулей — в jey_theme/access_control.py (BLOCKED_MODULES).
|
||||||
|
extend_bootinfo = "jey_theme.access_control.extend_bootinfo"
|
||||||
|
|
||||||
|
before_request = ["jey_theme.access_control.before_request"]
|
||||||
|
|
||||||
|
has_permission = {
|
||||||
|
"*": "jey_theme.access_control.has_permission",
|
||||||
|
}
|
||||||
|
|
||||||
# override_doctype_class = {
|
# override_doctype_class = {
|
||||||
# "Report": "jey_theme.custom_report.CustomReport"
|
# "Report": "jey_theme.custom_report.CustomReport"
|
||||||
# }
|
# }
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,19 @@
|
||||||
Jey Theme — Shared Styles (theme-independent)
|
Jey Theme — Shared Styles (theme-independent)
|
||||||
========================================================================== */
|
========================================================================== */
|
||||||
|
|
||||||
|
/* Hide Organization desktop icon by default */
|
||||||
|
.desktop-icon[data-id="Organization"] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fallback hide for blocked modules (main filtering happens in access_control.py).
|
||||||
|
Desktop Icon records use label "Quality" and "Subcontracting" — match those. */
|
||||||
|
.desktop-icon[data-id="Quality"],
|
||||||
|
.desktop-icon[data-id="Quality Management"],
|
||||||
|
.desktop-icon[data-id="Subcontracting"] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* ==========================================================================
|
/* ==========================================================================
|
||||||
Toggle Switch Checkboxes
|
Toggle Switch Checkboxes
|
||||||
========================================================================== */
|
========================================================================== */
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue