From 712d54b6e7902fd8717735cd69584c3a5da4705a Mon Sep 17 00:00:00 2001 From: Ali <010109ali@gmail.com> Date: Mon, 22 Jun 2026 16:22:03 +0000 Subject: [PATCH] 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 --- CLAUDE.md | 40 +++++++++---- jey_theme/access_control.py | 39 +++++++++++-- jey_theme/desk_layout.py | 112 ++++++++++++++++++------------------ jey_theme/hooks.py | 7 +++ 4 files changed, 127 insertions(+), 71 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e9bafd0..aff5e26 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/jey_theme/access_control.py b/jey_theme/access_control.py index e1be828..2272718 100644 --- a/jey_theme/access_control.py +++ b/jey_theme/access_control.py @@ -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) diff --git a/jey_theme/desk_layout.py b/jey_theme/desk_layout.py index bbe6f11..882fa27 100644 --- a/jey_theme/desk_layout.py +++ b/jey_theme/desk_layout.py @@ -166,10 +166,9 @@ def _build() -> dict: def _build_inner() -> dict: - if not is_enforcing(): - layout = {} - else: - layout = read_raw_layout() + # 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: """{: [, ...]} 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 {} diff --git a/jey_theme/hooks.py b/jey_theme/hooks.py index 12573ca..55b62bc 100644 --- a/jey_theme/hooks.py +++ b/jey_theme/hooks.py @@ -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 = {