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:
parent
20e7e834c2
commit
712d54b6e7
40
CLAUDE.md
40
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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Reference in New Issue