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 /
`get_desktop_page` cards+links) → absent in the DOM. URL still works.
- **block** = ALSO added to `blocked_doctypes/reports/pages` → denied at
`before_request` (HTTP/URL/API, catches Administrator) + `has_permission` (per-doc).
Truly unreachable for everyone.
- **block** = ALSO denied at the access layer, gated to PRODUCTION only (where
`jey_layout` is absent / `jey_design_mode` is 0; the design machine keeps access so
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
their card and reorders per `order`. Applied ALWAYS (even in edit mode); only
hide/block is gated by enforcement.
**Files**
- `desk_layout.py` — loads config; `_cache()`/`_build()` (enforcing-gated, fail-open)
produce hide/block sets cached on `frappe.local`; `_raw_workspaces()` +
`added_links()`/`order_map()`/`apply_order()` are the structural (always-on) overlay.
`is_enforcing()`: production → always; design machine → `not is_editing()`
- `desk_layout.py` — loads config; `_cache()`/`_build()` parse it ONCE per request
(ungated, fail-open) into hide/block sets on `frappe.local`. Two accessor families
with DIFFERENT gates: VISUAL overlay (`should_hide_*`, `hidden_or_blocked_workspaces`)
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
the `jey_layout` app is installed (test machines); `jey_design_mode` in
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
link like frappe's so the client renders it natively. Card labels in output are
translated, so matching forward-translates config labels (`_(label)`).
- `access_control.py``before_request` + `has_permission` deny `blocked`
doctypes/reports/pages (alongside static `BLOCKED_MODULES`); `extend_bootinfo`
strips hidden/blocked workspaces from boot and sets `bootinfo.jey_design_mode`.
All three are wrapped fail-open (never break the desk; errors → Error Log).
- `access_control.py` — enforcement. Static `BLOCKED_MODULES` (Quality/Subcontracting)
+ config reports/pages/workspaces are denied at `before_request` (HTTP, catches
Administrator) and `has_permission`. Config-blocked **doctypes** are denied via
`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
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]:
"""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)
if cached is not None:
return cached
doctypes = set(BLOCKED_DOCTYPES_EXTRA)
doctypes.update(desk_layout.blocked_doctypes())
if BLOCKED_MODULES:
try:
rows = frappe.get_all(
@ -44,11 +49,12 @@ def get_blocked_doctypes() -> 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]:
return desk_layout.blocked_pages()
return desk_layout.prod_blocked_pages()
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)
if cached is not None:
return cached
names: set[str] = set(desk_layout.blocked_workspaces())
names: set[str] = set(desk_layout.prod_blocked_workspaces())
try:
rows = frappe.get_all(
"Workspace",
@ -178,6 +184,10 @@ def _has_permission_impl(doc=None, ptype=None, user=None, debug=False):
return True
if is_blocked_doctype(doctype):
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).
docname = None
@ -197,6 +207,23 @@ def _has_permission_impl(doc=None, ptype=None, user=None, debug=False):
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):
if isinstance(container, dict):
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
# editor (kept out of app_include to avoid bloating the preload Link header).
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
allowed_modules = getattr(bootinfo, "allowed_modules", None)

View File

@ -166,9 +166,8 @@ def _build() -> dict:
def _build_inner() -> dict:
if not is_enforcing():
layout = {}
else:
# Always parse the config; gating is applied per-accessor (visual overlay is
# gated on is_enforcing(); access block is gated on not is_design_mode()).
layout = read_raw_layout()
workspaces = layout.get("workspaces") or {}
@ -224,59 +223,76 @@ def _cache() -> dict:
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:
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:
if not is_enforcing():
return False
cards = _ws_entry(ws_name).get("cards") or {}
entry = cards.get(card_label) or {}
return entry.get("state", VISIBLE) != VISIBLE
return (cards.get(card_label) or {}).get("state", VISIBLE) != VISIBLE
def hidden_card_labels(ws_name: str) -> frozenset[str]:
"""Untranslated Card Break labels marked hidden for this workspace.
Output card labels are translated (`_(label)`), so callers should forward-
translate these for matching rather than comparing raw."""
Output card labels are translated (`_(label)`), so callers forward-translate."""
if not is_enforcing():
return frozenset()
cards = _ws_entry(ws_name).get("cards") or {}
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:
"""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 {}
entry = links.get(link_key(link_type, link_to)) or {}
return entry.get("state", VISIBLE) != VISIBLE
return (links.get(link_key(link_type, link_to)) or {}).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
# 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.
# (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]:
"""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 = []
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"):
@ -311,8 +314,7 @@ def added_links(ws_name: str) -> list[dict]:
def order_map(ws_name: str) -> dict:
"""{<untranslated card label>: [<key>, ...]} desired link order per card."""
entry = _raw_workspaces().get(ws_name) or {}
order = entry.get("order")
order = _ws_entry(ws_name).get("order")
return order if isinstance(order, dict) else {}

View File

@ -28,6 +28,13 @@ 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
# server-side, before they reach the browser. See jey_theme/desk_layout.py.
override_whitelisted_methods = {