| """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 |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| _LEGACY_KEYS = {'myday': 'customer_data', 'customer_list': 'customer_data', |
| 'map': 'customer_data', 'library': 'settings', |
| |
| |
| |
| 'cohort': 'customer_data', |
| |
| |
| |
| |
| |
| |
| |
| 'agent': 'customer_data', |
| |
| |
| |
| |
| |
| 'ar': 'customer_data', |
| |
| |
| '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 |
|
|
|
|
| |
| 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 |
|
|
|
|
| |
| 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 |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| if m.get('validate_only') or m.get('api_surface') is False: |
| continue |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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 |
|
|