File size: 12,506 Bytes
c14ceee | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 | """core/perms.py β the permission + scope predicates as PURE functions of a user record.
EXIT-3b (2026-07-30). `ui/session.py` holds the same predicates keyed off `st.session_state`;
these take the user record explicitly, so the API can answer "what may this session see?" without
a Streamlit session. ONE implementation of each rule, two callers β and `verify_api.py` asserts
lock-step against `ui/session.py`'s source (the [[date-window-vocabulary]] discipline: a mirrored
vocabulary that is not gated has already drifted).
β THE TRAP THIS FILE EXISTS TO AVOID. `ui/session.brand_team()` reads the sidebar's BU selector
and defaults to `'All'` β `team_id=None` β CONSOLIDATED. Mirroring it literally on the API would
be a fail-OPEN: an API session has no selector, so a Royal-only user would be handed Fisch rows β
the exact leak strict isolation exists to prevent. The scope must be derived from the USER RECORD
(`bus`), never from a UI default. `scope_team_id()` below is that derivation.
Fail-closed everywhere: an unknown module key is denied, an unreadable `bus` narrows rather than
widens, and a user with no grant sees nothing rather than everything.
"""
import core.context as ctxlib
import core.registry as registry
import core.users as users
# Old module keys migrate ON READ β grants, Library prefs and ?page= deep links keep working
# without ever editing a stored user record. MUST stay identical to ui/session._LEGACY_KEYS
# (gated in verify_api.py): 'myday' shipped before the Customer List rename, 'customer_list'
# before the 2026-07-26 rename to 'customer_data', 'map' joined in wave 8 when the Map PAGE
# became a VIEW of the customer table. A chain is fine only if every hop resolves to the CURRENT
# key, so 'myday' points at the END of the chain, not at the middle.
# β 'customers' is deliberately NOT here. It is a real registry key β the parent of
# customer_data + cohort β and may_open() gives a parent grant to its children. Mapping it away
# would silently narrow every "customers" grant to one child.
_LEGACY_KEYS = {'myday': 'customer_data', 'customer_list': 'customer_data',
'map': 'customer_data', 'library': 'settings',
# wave 16 (item 5, R10): Cohort folded into the Customer rail as locked
# views β a cohort grant or ?page=cohort deep link lands on the grid that
# now hosts them.
'cohort': 'customer_data',
# wave 16 (item 7, R11): the Agents PAGE is deleted; an 'agent'-granted
# login's surviving surface is the Customer grid over their OWN m2m book β
# the legacy query-scope (scope_agent reads the record's agent LINK, not
# this list) keeps that book exact, shared customers included. The R1
# migration deliberately SKIPS agent-linked records: `agent is X` cannot
# express m2m membership (a shared customer displays its primary agent),
# so their wall stays legacy until a membership predicate exists.
'agent': 'customer_data',
# wave 17 (item 15, R2): the Collections PAGE is gone β its worklist is the
# shared "Collections" view on the Customer grid. An `ar` grant (or an
# ?page=ar deep link) lands on the surface that now does the job. The registry
# row survives as `validate_only`, so this is a grant migration, not a deletion:
# nobody loses access, they arrive somewhere that still exists.
'ar': 'customer_data',
# wave 17 (item 13, R3): Procurement folded into the Product grid β the buy
# list is a view over formula fields there.
'procurement': 'product_data'}
def is_admin(user):
return (user or {}).get('role') == 'admin'
def allowed_modules(user):
"""Registry keys this user may open β None means ALL (admins / unrestricted users)."""
m = (user or {}).get('modules', 'all')
if not m or m == 'all':
return None
return {_LEGACY_KEYS.get(k, k) for k in m}
def may_open(user, key):
"""True iff `user` may OPEN module `key`. A PARENT grant covers its sub-modules (granting
'customers' grants 'customer_data'). Fail-closed β an unknown key stays denied."""
am = allowed_modules(user)
if am is None or key in am:
return True
parent = registry.BY_KEY.get(key, {}).get('parent')
return parent in am if parent else False
# ------------------------------------------------------------------ BU (business unit) scope
def allowed_bu_labels(user):
"""The BU labels this user may see β `core.users.allowed_bus_labels`, which is already a
pure function of the record. Named here so callers have one import for the whole rule set."""
return users.allowed_bus_labels(user)
def allowed_team_ids(user):
"""The concrete Odoo team_ids this user may see, as a frozenset. `All` expands to every BU."""
labels = allowed_bu_labels(user)
if 'All' in labels:
return frozenset(t for t in ctxlib.BRANDS.values() if t is not None)
return frozenset(ctxlib.BRANDS[l] for l in labels
if l in ctxlib.BRANDS and ctxlib.BRANDS[l] is not None)
def scope_team_id(user):
"""The `team_id` an API request must query with β the ONE number that enforces BU isolation.
None means CONSOLIDATED, and it is returned ONLY when the user may genuinely see every BU
(`bus='all'`, or a multi-BU grant, both of which put 'All' in the label list). A user
permitted exactly one BU gets that BU's id PINNED, never None.
β This is the opposite default from `ui/session.brand_team()`, which falls back to 'All'
because a UI selector has to start somewhere. Here there is no selector to read, so the
fallback would BE the leak. A single-BU user's rows are pinned at the query, not filtered
afterwards, so there is no window in which the other BU's rows exist in the response.
"""
labels = allowed_bu_labels(user)
if 'All' in labels:
return None
ids = sorted(allowed_team_ids(user))
return ids[0] if len(ids) == 1 else None
def scope_agent(user):
"""The sales agent whose OWN BOOK this session is confined to, or None for the whole book.
β Mirrors `page_customer_data` (app.py), which reads `u.get('agent')` with NO admin
exemption β deliberately different from `page_agent`'s `None if is_admin() else β¦`. The
customer TABLE is the surface `/api/v1/customers` serves, so its rule is the one that
travels: an account linked to an agent sees that agent's book, admin or not. Getting this
wrong in the permissive direction would hand a restricted sales-agent login the whole book.
"""
return (user or {}).get('agent') or None
# ------------------------------------------------------------------ nav projection
def nav_pages(user):
"""The nav this user may see: registry order, `may_open`-filtered, archived EXCLUDED.
Shape (X2's `{key,label,source?,parent?}` plus ONE additive field, `chrome`):
[{key, label, source?, chrome: 'main'|'utility'}, β¦]
Four rules, each of which has bitten before:
* ARCHIVED IS INVISIBLE TO EVERYONE ([[module-archive-semantics]]) β not "hidden unless
admin". 18 of the 28 registry rows are archived and none may appear here.
* `group_only` ROWS ARE EXCLUDED ENTIRELY, and therefore **no `parent` is ever emitted**.
Wave-9 I8 flipped the host nav to a FLAT list: children became top-level and the folder
key was dropped, because a `group_only` key names a FAMILY and has no `PAGE_FUNCS` entry
β rendering a row for it is a white screen one click away (app.py:8182-8187 says exactly
this). `customers` is the only such row. Emitting `parent: 'customers'` on its children
while the parent itself is filtered out would hand the client a DANGLING reference to a
row that is not in the payload, which is worse than a flat list β so the flat list is
what ships, matching the surface a user actually sees today.
* `nav: False` IS NOT `archived`. `settings`, `analyst` and `dictionary` are LIVE surfaces
the host renders outside the module list (Analyst is the unrestricted landing page,
Settings sits in the chrome). Dropping them would leave the shell unable to reach two of
the nine live surfaces, so they ship flagged `chrome: 'utility'` and the client places
them where the host does. `chrome: 'main'` is the module list.
* NOT filtered by `ui/session.user_enabled_modules()`. That is a PREFERENCE read from
`platform/data/store/prefs.json` β a local file on one box. Consuming it here
would put per-user FILESYSTEM state inside a process EXIT-4 requires to be stateless,
and the same user's nav would differ per container. It is a Library setting, not a
permission; the permission gate is `may_open`, and that is what this applies.
"""
out = []
for m in registry.REGISTRY:
if m.get('archived') or m.get('group_only'):
continue
# WAVE 17 (R2 + R8) β `validate_only` rows have NO SURFACE ANYWHERE. Not the module
# list, not the utility group behind the account menu, not a page. They exist so
# `validate.py` keeps proving contracts that a LIVE surface depends on:
# Β· `ar` β the Collections page is gone (its worklist is a saved view on the
# Customer grid now), but `ar.validate()` is what reconciles the
# AR columns that view is built from. Archiving the row would have
# skipped it (validate.py skips archived), deleting the proof while
# keeping the numbers β the worst of both.
# Β· `dictionary` β the semantic-layer contracts, the Analyst's grounding proof.
# `api_surface: False` is the NARROWER neighbour: the row still has a Streamlit page,
# but it is not a surface of the PRODUCT. `ar` is the case β its Collections worklist
# is a saved view on the Customer grid now, while the admin-only Statements sender
# (the one sanctioned Odoo writer) keeps its host page.
# β This is NOT `nav: False`. That flag means "a live surface the HOST renders outside
# the module list" and ships as `chrome: 'utility'`, which is exactly the account-menu
# row R8 says to delete.
if m.get('validate_only') or m.get('api_surface') is False:
continue
# WAVE 17 R8, the other half β `settings` STOPS being offered to the API's clients.
# It shipped as `chrome: 'utility'`, which the React shell rendered as "App settings":
# a hand-off that opens the STREAMLIT settings page in a new tab, beside the shell's
# own Profile modal that does the same job better. Two doors to one room, one of them
# leading out of the application. The owner's words were "I don't even know what it
# does β delete them".
# β The ROW STAYS, and so does the Streamlit page behind it: this host's own account
# menu still opens it, and the dispatch guard uses `settings` as its always-renderable
# last resort for a user whose whole grant has been retired. What changes is that the
# API no longer advertises it as a surface of the PRODUCT.
if m['key'] == 'settings':
continue
if not may_open(user, m['key']):
continue
row = {'key': m['key'], 'label': m['label'],
'chrome': 'main' if m.get('nav', True) else 'utility'}
if m.get('source'):
row['source'] = m['source']
out.append(row)
return out
def landing_page(user):
"""Where a restricted user lands: the first MAIN-chrome page in their grant. None for
unrestricted users (the client lands on the Analyst, as the host does).
Mirrors `ui/session.first_allowed_module` minus its archived last-resort leg β that leg
exists so a misconfigured Streamlit login is not bricked; an API caller gets an explicit
answer instead, and serving an archived key would contradict the rule above.
"""
if allowed_modules(user) is None:
return None
for row in nav_pages(user):
if row['chrome'] == 'main':
return row['key']
return None
|