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:
Ali 2026-04-13 11:54:48 +00:00
parent a217ea104a
commit 96099513a1
3 changed files with 263 additions and 0 deletions

240
jey_theme/access_control.py Normal file
View File

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

View File

@ -18,6 +18,16 @@ app_include_css = [
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 = {
# "Report": "jey_theme.custom_report.CustomReport"
# }

View File

@ -2,6 +2,19 @@
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
========================================================================== */