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