feat: enforce config doctype-block via permission hooks (SM-proof)

Move config-driven doctype blocking off before_request onto the permission
system so a System Manager cannot undo it from the UI:

- permission_query_conditions ("*") returns 1=0 for prod-blocked doctypes
  -> list views come back empty for everyone but Administrator.
- has_permission denies every per-doc op (read/create/write/delete).
- extend_bootinfo strips blocked doctypes from user.can_*.

Doctypes are deliberately NOT blocked at before_request: a blunt URL/API
block breaks search_link/dynamic links, while permission hooks are bypassed
by ignore_permissions so internal/framework reads keep working.

Split desk_layout gating: the visual overlay stays gated on is_enforcing();
access-block accessors (prod_blocked_*) gate on 'not is_design_mode()' so a
block only bites on production (the design machine keeps full access).
Reports/pages/workspaces blocking unchanged (before_request + has_permission).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ali 2026-06-22 16:22:03 +00:00
parent 20e7e834c2
commit 712d54b6e7
4 changed files with 127 additions and 71 deletions

View File

@ -60,18 +60,31 @@ Key for every link = `link_type::link_to` (Type ∈ DocType/Report/Page).
- **hide** = the item is filtered OUT of the data the server sends (sidebar / - **hide** = the item is filtered OUT of the data the server sends (sidebar /
`get_desktop_page` cards+links) → absent in the DOM. URL still works. `get_desktop_page` cards+links) → absent in the DOM. URL still works.
- **block** = ALSO added to `blocked_doctypes/reports/pages` → denied at - **block** = ALSO denied at the access layer, gated to PRODUCTION only (where
`before_request` (HTTP/URL/API, catches Administrator) + `has_permission` (per-doc). `jey_layout` is absent / `jey_design_mode` is 0; the design machine keeps access so
Truly unreachable for everyone. admins can manage). Two enforcement paths:
- **doctypes**`permission_query_conditions` (`*` → returns `1=0`, so the list is
EMPTY for everyone incl. System Manager) + `has_permission` (per-doc deny, every
ptype: read/create/write/delete) + `extend_bootinfo` (stripped from `can_*`). NOT
`before_request`: a blunt URL/API block on a doctype breaks `search_link`/dynamic
links, whereas permission hooks are bypassed by `ignore_permissions` so those
internal reads keep working. Code-enforced ⇒ **SM-proof** (lives in code+config,
not DocPerm, so a System Manager can't undo it from the UI). Administrator is the
only break-glass bypass.
- **reports / pages / workspaces**`before_request` (HTTP/URL/API, catches
Administrator too) + `has_permission` (per-doc). Truly unreachable for everyone.
- **add / reorder** = structural overlay: `get_desktop_page` injects added links into - **add / reorder** = structural overlay: `get_desktop_page` injects added links into
their card and reorders per `order`. Applied ALWAYS (even in edit mode); only their card and reorders per `order`. Applied ALWAYS (even in edit mode); only
hide/block is gated by enforcement. hide/block is gated by enforcement.
**Files** **Files**
- `desk_layout.py` — loads config; `_cache()`/`_build()` (enforcing-gated, fail-open) - `desk_layout.py` — loads config; `_cache()`/`_build()` parse it ONCE per request
produce hide/block sets cached on `frappe.local`; `_raw_workspaces()` + (ungated, fail-open) into hide/block sets on `frappe.local`. Two accessor families
`added_links()`/`order_map()`/`apply_order()` are the structural (always-on) overlay. with DIFFERENT gates: VISUAL overlay (`should_hide_*`, `hidden_or_blocked_workspaces`)
`is_enforcing()`: production → always; design machine → `not is_editing()` is gated on `is_enforcing()`; ACCESS-block (`prod_blocked_doctypes`/`_reports`/
`_pages`/`_workspaces`) is gated on `not is_design_mode()` (block bites on prod only).
`_ws_entry()` + `added_links()`/`order_map()`/`apply_order()` are the structural
(always-on) overlay. `is_enforcing()`: production → always; design machine → `not is_editing()`
(Redis key `jey_layout_editing`). `is_design_mode()` defaults to ON exactly when (Redis key `jey_layout_editing`). `is_design_mode()` defaults to ON exactly when
the `jey_layout` app is installed (test machines); `jey_design_mode` in the `jey_layout` app is installed (test machines); `jey_design_mode` in
site_config overrides (0/1). Tolerates missing refs. site_config overrides (0/1). Tolerates missing refs.
@ -80,10 +93,15 @@ Key for every link = `link_type::link_to` (Type ∈ DocType/Report/Page).
→ hide/block filter (only when enforcing). `_build_link_item` shapes an injected → hide/block filter (only when enforcing). `_build_link_item` shapes an injected
link like frappe's so the client renders it natively. Card labels in output are link like frappe's so the client renders it natively. Card labels in output are
translated, so matching forward-translates config labels (`_(label)`). translated, so matching forward-translates config labels (`_(label)`).
- `access_control.py``before_request` + `has_permission` deny `blocked` - `access_control.py` — enforcement. Static `BLOCKED_MODULES` (Quality/Subcontracting)
doctypes/reports/pages (alongside static `BLOCKED_MODULES`); `extend_bootinfo` + config reports/pages/workspaces are denied at `before_request` (HTTP, catches
strips hidden/blocked workspaces from boot and sets `bootinfo.jey_design_mode`. Administrator) and `has_permission`. Config-blocked **doctypes** are denied via
All three are wrapped fail-open (never break the desk; errors → Error Log). `permission_query_conditions` (`*` → `1=0`) + `has_permission` (per-doc) ONLY — not
`before_request` (keeps `search_link`/dynamic links working). `extend_bootinfo`
strips blocked modules/doctypes (`can_*`) and hidden/blocked workspaces from boot,
and sets `bootinfo.jey_design_mode`. All hooks are wrapped fail-open (never break the
desk; `has_permission` returns True on error, `before_request` re-raises only the
intended `PermissionError`; errors → Error Log).
**Edit vs applied model** (no "preview"): on the design machine, the normal desk is **Edit vs applied model** (no "preview"): on the design machine, the normal desk is
the *applied* result; "✎ Edit layout" flips `jey_layout_editing` and reloads to show the *applied* result; "✎ Edit layout" flips `jey_layout_editing` and reloads to show

View File

@ -23,11 +23,16 @@ def _slugify(name: str) -> str:
def get_blocked_doctypes() -> frozenset[str]: def get_blocked_doctypes() -> frozenset[str]:
"""Doctypes blocked by the static module list (Quality/Subcontracting).
NOT the config-driven jey_layout block that is enforced via the permission
hooks (has_permission + permission_query_conditions) keyed off
desk_layout.prod_blocked_doctypes(), so it doesn't go through before_request
(which would break search_link / dynamic links for core doctypes)."""
cached = getattr(frappe.local, "_jey_blocked_doctypes", None) cached = getattr(frappe.local, "_jey_blocked_doctypes", None)
if cached is not None: if cached is not None:
return cached return cached
doctypes = set(BLOCKED_DOCTYPES_EXTRA) doctypes = set(BLOCKED_DOCTYPES_EXTRA)
doctypes.update(desk_layout.blocked_doctypes())
if BLOCKED_MODULES: if BLOCKED_MODULES:
try: try:
rows = frappe.get_all( rows = frappe.get_all(
@ -44,11 +49,12 @@ def get_blocked_doctypes() -> frozenset[str]:
def get_blocked_reports() -> frozenset[str]: def get_blocked_reports() -> frozenset[str]:
return desk_layout.blocked_reports() # Config-driven, prod-gated (blocked only where jey_layout is NOT installed).
return desk_layout.prod_blocked_reports()
def get_blocked_pages() -> frozenset[str]: def get_blocked_pages() -> frozenset[str]:
return desk_layout.blocked_pages() return desk_layout.prod_blocked_pages()
def is_blocked_report(report: str) -> bool: def is_blocked_report(report: str) -> bool:
@ -59,7 +65,7 @@ def get_blocked_workspace_names() -> frozenset[str]:
cached = getattr(frappe.local, "_jey_blocked_workspaces", None) cached = getattr(frappe.local, "_jey_blocked_workspaces", None)
if cached is not None: if cached is not None:
return cached return cached
names: set[str] = set(desk_layout.blocked_workspaces()) names: set[str] = set(desk_layout.prod_blocked_workspaces())
try: try:
rows = frappe.get_all( rows = frappe.get_all(
"Workspace", "Workspace",
@ -178,6 +184,10 @@ def _has_permission_impl(doc=None, ptype=None, user=None, debug=False):
return True return True
if is_blocked_doctype(doctype): if is_blocked_doctype(doctype):
return False return False
# Config-driven block (prod only, i.e. where jey_layout is NOT installed).
# Per-doc deny; the empty list is handled by permission_query_conditions.
if doctype in desk_layout.prod_blocked_doctypes():
return False
# Name of the doc being checked (for Report/Page/Workspace identity blocks). # Name of the doc being checked (for Report/Page/Workspace identity blocks).
docname = None docname = None
@ -197,6 +207,23 @@ def _has_permission_impl(doc=None, ptype=None, user=None, debug=False):
return True return True
def permission_query_conditions(user=None, doctype=None):
"""List-query filter (registered for "*"). Returns "1=0" for config-blocked
doctypes on production their list views come back EMPTY, for everyone incl.
System Manager, and it cannot be undone from the UI (lives in code).
search_link and the framework's internal reads use ignore_permissions and
bypass this, so dynamic links / dropdowns keep working. Never raises."""
try:
if user == "Administrator":
return "" # break-glass superuser keeps full list access
if doctype and doctype in desk_layout.prod_blocked_doctypes():
return "1=0"
except Exception:
_safe_log("permission_query_conditions failed")
return ""
def _filter_list(container, key, predicate): def _filter_list(container, key, predicate):
if isinstance(container, dict): if isinstance(container, dict):
value = container.get(key) value = container.get(key)
@ -231,7 +258,9 @@ def _extend_bootinfo_impl(bootinfo):
# Flag for jey_theme.js to decide whether to dynamically load the jey_layout # Flag for jey_theme.js to decide whether to dynamically load the jey_layout
# editor (kept out of app_include to avoid bloating the preload Link header). # editor (kept out of app_include to avoid bloating the preload Link header).
bootinfo.jey_design_mode = desk_layout.is_design_mode() bootinfo.jey_design_mode = desk_layout.is_design_mode()
blocked_doctypes = get_blocked_doctypes() # Strip from boot discovery lists (awesomebar/new): static module block +
# config-driven prod block (so prod-blocked doctypes don't show in search).
blocked_doctypes = get_blocked_doctypes() | desk_layout.prod_blocked_doctypes()
blocked_modules = BLOCKED_MODULES blocked_modules = BLOCKED_MODULES
allowed_modules = getattr(bootinfo, "allowed_modules", None) allowed_modules = getattr(bootinfo, "allowed_modules", None)

View File

@ -166,10 +166,9 @@ def _build() -> dict:
def _build_inner() -> dict: def _build_inner() -> dict:
if not is_enforcing(): # Always parse the config; gating is applied per-accessor (visual overlay is
layout = {} # gated on is_enforcing(); access block is gated on not is_design_mode()).
else: layout = read_raw_layout()
layout = read_raw_layout()
workspaces = layout.get("workspaces") or {} workspaces = layout.get("workspaces") or {}
@ -224,59 +223,76 @@ def _cache() -> dict:
return cached return cached
# ---------------------------------------------------------------------------
# Public derived accessors (config-driven)
# ---------------------------------------------------------------------------
def hidden_or_blocked_workspaces() -> frozenset[str]:
"""Workspaces to remove from the sidebar (hidden + blocked)."""
c = _cache()
return c["hidden_ws"] | c["blocked_ws"]
def blocked_workspaces() -> frozenset[str]:
return _cache()["blocked_ws"]
def blocked_doctypes() -> frozenset[str]:
return _cache()["blocked_doctypes"]
def blocked_reports() -> frozenset[str]:
return _cache()["blocked_reports"]
def blocked_pages() -> frozenset[str]:
return _cache()["blocked_pages"]
def _ws_entry(ws_name: str) -> dict: def _ws_entry(ws_name: str) -> dict:
return _cache()["workspaces"].get(ws_name) or {} return _cache()["workspaces"].get(ws_name) or {}
def is_workspace_visible(ws_name: str) -> bool: # ---------------------------------------------------------------------------
return _ws_entry(ws_name).get("state", VISIBLE) == VISIBLE # VISUAL overlay accessors — gated on is_enforcing() (edit/applied model).
# In edit mode nothing is hidden (operator sees everything); when applied, the
# layout is shown filtered.
# ---------------------------------------------------------------------------
def hidden_or_blocked_workspaces() -> frozenset[str]:
"""Workspaces to remove from the sidebar (hidden + blocked) — visual only."""
if not is_enforcing():
return frozenset()
c = _cache()
return c["hidden_ws"] | c["blocked_ws"]
def should_hide_card(ws_name: str, card_label: str) -> bool: def should_hide_card(ws_name: str, card_label: str) -> bool:
if not is_enforcing():
return False
cards = _ws_entry(ws_name).get("cards") or {} cards = _ws_entry(ws_name).get("cards") or {}
entry = cards.get(card_label) or {} return (cards.get(card_label) or {}).get("state", VISIBLE) != VISIBLE
return entry.get("state", VISIBLE) != VISIBLE
def hidden_card_labels(ws_name: str) -> frozenset[str]: def hidden_card_labels(ws_name: str) -> frozenset[str]:
"""Untranslated Card Break labels marked hidden for this workspace. """Untranslated Card Break labels marked hidden for this workspace.
Output card labels are translated (`_(label)`), so callers should forward- Output card labels are translated (`_(label)`), so callers forward-translate."""
translate these for matching rather than comparing raw.""" if not is_enforcing():
return frozenset()
cards = _ws_entry(ws_name).get("cards") or {} cards = _ws_entry(ws_name).get("cards") or {}
return frozenset(label for label, entry in cards.items() if (entry or {}).get("state", VISIBLE) != VISIBLE) return frozenset(label for label, entry in cards.items() if (entry or {}).get("state", VISIBLE) != VISIBLE)
def should_hide_link(ws_name: str, link_type: str, link_to: str) -> bool: def should_hide_link(ws_name: str, link_type: str, link_to: str) -> bool:
"""True for both 'hidden' and 'blocked' links (both removed from display).""" """True for 'hidden' and 'blocked' links (both removed from the desk display)."""
if not is_enforcing():
return False
links = _ws_entry(ws_name).get("links") or {} links = _ws_entry(ws_name).get("links") or {}
entry = links.get(link_key(link_type, link_to)) or {} return (links.get(link_key(link_type, link_to)) or {}).get("state", VISIBLE) != VISIBLE
return entry.get("state", VISIBLE) != VISIBLE
# ---------------------------------------------------------------------------
# ACCESS-BLOCK accessors — gated on NOT is_design_mode() (enforced on machines
# WITHOUT jey_layout, i.e. production; the design machine keeps full access so
# admins can manage). Enforced via permission hooks (has_permission +
# permission_query_conditions) so a System Manager cannot undo it from the UI.
# ---------------------------------------------------------------------------
def prod_blocked_doctypes() -> frozenset[str]:
if is_design_mode():
return frozenset()
return _cache()["blocked_doctypes"]
def prod_blocked_reports() -> frozenset[str]:
if is_design_mode():
return frozenset()
return _cache()["blocked_reports"]
def prod_blocked_pages() -> frozenset[str]:
if is_design_mode():
return frozenset()
return _cache()["blocked_pages"]
def prod_blocked_workspaces() -> frozenset[str]:
if is_design_mode():
return frozenset()
return _cache()["blocked_ws"]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -284,24 +300,11 @@ def should_hide_link(ws_name: str, link_type: str, link_to: str) -> bool:
# These are NOT gated by is_enforcing() — adding a link and reordering are # These are NOT gated by is_enforcing() — adding a link and reordering are
# structural and must apply in edit mode too (so the operator can see/manage # structural and must apply in edit mode too (so the operator can see/manage
# them). Hide/block (above) stays gated, so edit mode shows everything. # them). Hide/block (above) stays gated, so edit mode shows everything.
# (Read straight from the parsed config, which _cache() holds ungated.)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_RAW_ATTR = "_jey_layout_raw"
def _raw_workspaces() -> dict:
cached = getattr(frappe.local, _RAW_ATTR, None)
if cached is None:
try:
cached = read_raw_layout().get("workspaces") or {}
except Exception:
cached = {}
setattr(frappe.local, _RAW_ATTR, cached)
return cached
def added_links(ws_name: str) -> list[dict]: def added_links(ws_name: str) -> list[dict]:
"""List of injected links for a workspace: {link_type, link_to, label, card}.""" """List of injected links for a workspace: {link_type, link_to, label, card}."""
entry = _raw_workspaces().get(ws_name) or {} entry = _ws_entry(ws_name)
out = [] out = []
for a in (entry.get("added") or {}).values(): for a in (entry.get("added") or {}).values():
if isinstance(a, dict) and a.get("link_to") and a.get("link_type") and a.get("card"): if isinstance(a, dict) and a.get("link_to") and a.get("link_type") and a.get("card"):
@ -311,8 +314,7 @@ def added_links(ws_name: str) -> list[dict]:
def order_map(ws_name: str) -> dict: def order_map(ws_name: str) -> dict:
"""{<untranslated card label>: [<key>, ...]} desired link order per card.""" """{<untranslated card label>: [<key>, ...]} desired link order per card."""
entry = _raw_workspaces().get(ws_name) or {} order = _ws_entry(ws_name).get("order")
order = entry.get("order")
return order if isinstance(order, dict) else {} return order if isinstance(order, dict) else {}

View File

@ -28,6 +28,13 @@ has_permission = {
"*": "jey_theme.access_control.has_permission", "*": "jey_theme.access_control.has_permission",
} }
# Config-driven block (prod only): empty the list view for blocked doctypes.
# Code-enforced (System Manager can't undo it from the UI). search_link / internal
# reads use ignore_permissions and bypass this, so dynamic links keep working.
permission_query_conditions = {
"*": "jey_theme.access_control.permission_query_conditions",
}
# Desk layout engine (variant A): filter workspace sidebar & page content # Desk layout engine (variant A): filter workspace sidebar & page content
# server-side, before they reach the browser. See jey_theme/desk_layout.py. # server-side, before they reach the browser. See jey_theme/desk_layout.py.
override_whitelisted_methods = { override_whitelisted_methods = {