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"
|
||||
|
||||
# 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"
|
||||
# }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
========================================================================== */
|
||||
|
|
|
|||
Loading…
Reference in New Issue