"""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