File size: 16,745 Bytes
c14ceee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91de770
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c14ceee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91de770
 
 
 
 
 
 
 
 
 
c14ceee
 
 
91de770
 
 
 
 
 
 
 
 
 
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
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
"""core/perm_scope.py β€” the permission WALL for table modules (wave 15, C-PERM).

ONE place answers the three questions a restricted account raises on a grid surface:

    may_access(user, module)          may they open it at all?
    visible_fields(fields, u, mod)    which COLUMNS may they receive?
    apply_row_scope(rows, u, mod, …)  which ROWS may they receive?

plus one that exists only because of how the pool is built:

    derive_pool_scope(user, module)   which (team_id, agent) must the pool be BUILT with?

Both hosts call these β€” `aios-web/api` (`grid_assembly`) and `app.py` (`_table_grid`) β€” because
a wall that exists on one runtime and not the other is not a wall. `core/perms.py` stays what it
is (module GRANTS + the legacy BU derivation); this module is the row/field/pushdown layer that
sits on top, and it is deliberately a separate file so the legacy readers can keep their
semantics untouched while this one fails closed.

────────────────────────────────────────────────────────────────────────────────────────────
THE RECORD

    user['perms']   = {'<module_key>': {'access': bool,
                                        'filter': {'conj'?: 'and'|'or', 'nodes': [...]} | None,
                                        'hiddenFields': ['<field key>', ...]}}
    user['perms_v'] = 1        stamped by the migration and by every write

`perms_v` is the EXPLICIT-RESOLUTION marker, and it is here because of `permissioning.md`
Part II gap #5: "`allowed_modules() is None` is fail-open by default … make 'resolved:
unrestricted' an explicit value so absence/uncertainty DENIES." The same class already shipped
twice in this codebase (`modules: []` and `bus: [<unknown id>]` both read as UNRESTRICTED β€”
`routes_admin.py`'s own docstring documents both). So:

  * `perms_v` ABSENT  β†’ the record is UN-MIGRATED, and the LEGACY wall applies unchanged
    (`core.perms` module grants + the `bus`/`agent` query scope). That is not fail-open: it is
    today's real wall, and it bounds the rollout window to "until the migration runs".
  * `perms_v == 1` and the module has NO entry β†’ **DENY**. Absence now means what it says.
  * `role == 'admin'` bypasses all of it β€” which is also what keeps BREAK-GLASS alive.
    `deps._user_for` hands back a hardcoded master dict on a store outage
    (`{'username':'admin','role':'admin','bus':'all','modules':'all'}`) that will never carry a
    perms block; without this clause an explicit-marker scheme locks the owner out of their own
    product at exactly the moment the store is broken.
"""
import re

import core.perms as perms

#: `aios_grid._FORMULA_REF`'s pattern, restated rather than imported: this module is imported by
#: the API's request path and `aios_grid` pulls in the whole grid stack. Same regex, one line,
#: and `verify_api` asserts the two agree so it cannot drift into a different grammar.
_FORMULA_REF = re.compile(r"\{([^{}]*)\}")

PERMS_VERSION = 1


def _rec(user):
    return user if isinstance(user, dict) else {}


def is_migrated(user):
    """True once this record carries an explicit resolution. See the module docstring."""
    return int(_rec(user).get('perms_v') or 0) >= PERMS_VERSION


def entry(user, module):
    """This user's declared permissions for `module`, or None if nothing is declared.

    None is AMBIGUOUS on purpose and every caller must resolve it against `is_migrated`:
    on a migrated record it means DENY, on a legacy one it means "ask the old wall".
    """
    p = _rec(user).get('perms')
    if not isinstance(p, dict):
        return None
    e = p.get(module)
    return e if isinstance(e, dict) else None


def may_access(user, module):
    """May this account open `module` at all? Fail-closed on a migrated record."""
    if perms.is_admin(user):
        return True
    e = entry(user, module)
    if e is not None:
        return bool(e.get('access', True))
    if is_migrated(user):
        # Migrated and undeclared = denied. This is the whole point of the marker.
        return False
    return perms.may_open(user, module)          # legacy record: the old grant wall


# ── FIELDS ───────────────────────────────────────────────────────────────────────────────────
def hidden_keys(user, module, fields):
    """The TRANSITIVE closure of hidden field keys (C-PERM amendment 5).

    β›” WHY A CLOSURE AND NOT A SET DIFFERENCE. A formula field is computed in the BROWSER
    (`formulaEngine.ts`, injected by `computedRows`) from `{ref}`s into other columns, and a
    measure column's value arrives precomputed in `derived`. So hiding field X has exactly three
    possible outcomes and only one of them is coherent:

        strip X, keep formulas    β†’ every formula over X computes blank or wrong, silently
        keep X's value for them   β†’ X has leaked, wearing a formula's name
        strip X AND its dependents→ the only honest answer

    So a hidden field drags every formula that references it β€” and every formula that references
    THAT formula, hence the fixpoint loop β€” out of the payload with it.

    ⚠ This runs on every assembly, so it is a fixpoint over a handful of custom fields, not a
    graph library. `MAX_PASSES` bounds a reference cycle the client would refuse to evaluate
    anyway; without it a self-referential pair would spin here.
    """
    e = entry(user, module)
    if perms.is_admin(user) or not e:
        return frozenset()
    hidden = {str(k) for k in (e.get('hiddenFields') or ()) if k}
    if not hidden:
        return frozenset()

    refs = {}
    for f in fields or ():
        if not isinstance(f, dict) or not f.get('key'):
            continue
        expr = f.get('formula')
        if isinstance(expr, str) and expr:
            refs[f['key']] = {m.strip() for m in _FORMULA_REF.findall(expr) if m.strip()}

    MAX_PASSES = 12
    for _ in range(MAX_PASSES):
        grew = False
        for key, deps in refs.items():
            if key not in hidden and deps & hidden:
                hidden.add(key)
                grew = True
        if not grew:
            break
    return frozenset(hidden)


def visible_fields(fields, user, module):
    """`fields` minus the hidden closure. Order preserved β€” the column order is the user's."""
    hide = hidden_keys(user, module, fields)
    if not hide:
        return list(fields or ())
    return [f for f in (fields or ())
            if not (isinstance(f, dict) and f.get('key') in hide)]


def strip_row(row, hide):
    """Drop hidden keys from ONE assembled row. Cheap enough to run per row, and it must run
    per row: the field list and the row payload are two different wires, and stripping only the
    first would leave the value sitting in the second where anyone can read it."""
    if not hide or not isinstance(row, dict):
        return row
    return {k: v for k, v in row.items() if k not in hide}


# ── ROWS ─────────────────────────────────────────────────────────────────────────────────────
def apply_row_scope(rows, user, module, fields, ctx=None):
    """The rows this account may receive: `filter_eval.permits` over the permanent filter.

    `permits`, never `matches` β€” an unanswerable permanent filter DENIES rather than being
    ignored. See `harness/filter_eval`'s docstring for the field-rename walkthrough that makes
    the difference a leak rather than a preference.
    """
    if perms.is_admin(user):
        return list(rows or ())
    e = entry(user, module)
    tree = (e or {}).get('filter')
    if not tree:
        return list(rows or ())
    from harness import filter_eval as fe
    return [r for r in (rows or ()) if fe.permits(tree, r, fields, ctx)]


# ── THE PUSHDOWN ─────────────────────────────────────────────────────────────────────────────
#: `dba` value sets that pin a BU, and the Odoo team they pin. `_dba_attrs` emits exactly
#: {'Fisch','Royal','Both'} (blank for "no brand attributable in 24 months"), so a permission
#: filter admitting Fisch admits {Fisch, Both} β€” a Both customer IS a Fisch customer.
_DBA_TEAM = ((frozenset({'fisch', 'both'}), 5), (frozenset({'royal', 'both'}), 6))

#: Only equality pushes down. `FILTER_OPS` is single-valued (there is no column-level "is any
#: of" β€” that vocabulary belongs to cohort leaves and is disjoint), so a multi-value BU
#: condition arrives as an OR GROUP of `eq` leaves, handled by `_group_dba_team` below.
_PUSHDOWN_OPS = frozenset({'eq'})

#: ⭐ THE MODULES WHOSE FIELD VOCABULARY CAN EXPRESS A BUSINESS UNIT β€” i.e. whose canonical
#: contract carries a `dba` column. R1's "BU access is just a permanent filter" holds only on
#: these; on every other topic there is no column to write the condition against, so a filter
#: literally cannot say it and the record's `bus` remains the only place the fact lives.
#:
#: `product_data` is the counter-example that made this constant necessary: 19 fields, no brand
#: column, and a product's BU is a property of WHOSE ORDERS built its revenue rather than of the
#: SKU. Without the fallback below, a Fisch-only account read the product catalogue with
#: Fisch+Royal money on every row β€” the amendment-3 defect, arriving through the door amendment 3
#: was written to close.
#:
#: ⚠ A LIST THAT MIRRORS A JSON CONTRACT DRIFTS UNLESS SOMETHING CHECKS IT. `verify_api` asserts
#: membership here matches "this topic's contract has a `dba` field" for every governed module, so
#: a topic that grows or loses a brand column cannot silently keep the wrong rule. Named in this
#: module rather than derived from `aios_grid` on purpose: `perm_scope` is on the API's request
#: path and importing the grid stack to answer a two-element question is a cost per request.
BU_FILTERABLE_MODULES = frozenset({'customer_data'})


def derive_pool_scope(user, module):
    """`(team_id, agent)` the POOL must be BUILT with, derived from the permanent filter.

    β›” THIS EXISTS BECAUSE `team_id` SHAPES VALUES, NOT ROW MEMBERSHIP (C-PERM amendment 3).
    `modules/customer_data._pool_build` passes `team_id` into `cust._cust_rev` three times (YTD,
    LY, LTM) and into `cust._cadence_bulk`, so it decides what `rev`, `ly`, `ltm`, `aov`,
    `est_missed` and the derived `status` MEAN. Enforce a BU purely as a post-filter and a
    Fisch-only user keeps a correct-looking row LIST while every number on it silently becomes
    Fisch+Royal β€” worst for `dba = Both` customers, who are exactly the ones a BU filter admits.
    A pid-level reconciliation cannot see that; the values one can, and does.

    So the query-level pushdown SURVIVES β€” but as a DERIVATION OF the permanent filter rather
    than a second wall beside it, which is what keeps R1's "BU access is just a filter" true at
    the level the owner asked for it (one declaration, one UI, one engine).

    Pure, and recomputed per request rather than stored: a stored derivation drifts from the
    filter it came from, and then two things disagree about what an account may see.

    Reads TOP-LEVEL AND-conjunction leaves ONLY. A leaf under `or` guarantees no narrowing β€”
    `dba is Fisch OR revenue > 10` must not pin the pool to Fisch β€” so it never pushes down.
    Anything not recognised here simply is not pushed down; `apply_row_scope` still applies the
    whole tree, so the wall is unchanged either way. Belt AND braces, deliberately: the pushdown
    is what makes the VALUES right, `permits()` is what makes the ROWS right.

    ⭐ THE `bus` FALLBACK, AND WHY IT IS NOT A HOLE IN AMENDMENT 4. On a topic outside
    `BU_FILTERABLE_MODULES` there is no column a BU condition could be written against, so
    "the filter pins no team" cannot mean "the admin chose consolidated" β€” it is the only answer
    the filter language has. Resolving that silence as None returns the WIDER scope, which makes
    the current code fail-OPEN on the values axis for exactly the topic that cannot argue back.
    So the record's own `bus` answers instead, and the direction is what makes it safe: this can
    only ever REPLACE None (both units) with a pinned single unit. It never widens, it never
    touches `may_access`, and a `bus:'all'` account is unaffected because `scope_team_id` returns
    None for it β€” which is every account in tenant #0's registry except the one this shipped for.
    """
    if perms.is_admin(user):
        return None, None
    team_id, agent = _derive_from_filter(user, module)
    if team_id is None and module not in BU_FILTERABLE_MODULES:
        team_id = perms.scope_team_id(user)
    return team_id, agent


def _derive_from_filter(user, module):
    """`(team_id, agent)` the PERMANENT FILTER pins, before any fallback. Split out so the
    fallback has exactly one place to apply β€” the three exits below all mean "the filter pinned
    nothing", and a rule written at each of them is a rule that will one day be written at two."""
    e = entry(user, module)
    tree = (e or {}).get('filter')
    if not tree:
        # Un-migrated records still answer through the legacy derivation, so the old wall keeps
        # working until the migration has run.
        if not is_migrated(user):
            return perms.scope_team_id(user), perms.scope_agent(user)
        return None, None

    from harness import filter_eval as fe
    nodes, conj = fe.tree_parts(tree)
    if conj == 'or':
        return None, None

    team_id, agent = None, None
    for n in nodes:
        if not isinstance(n, dict):
            continue
        if isinstance(n.get('children'), list):
            # A top-level OR GROUP under an AND root IS a guaranteed narrowing β€” every row must
            # satisfy it β€” so it may push down, unlike a leaf under an OR ROOT (refused above).
            # This is the shape a multi-value BU condition actually takes; see `_group_dba_team`.
            tid = _group_dba_team(n)
            if tid is not None:
                team_id = tid if team_id in (None, tid) else None
            continue
        if n.get('op') not in _PUSHDOWN_OPS:
            continue
        col = n.get('colId')
        raw = n.get('value')
        if col == 'dba':
            vals = {v.strip().lower() for v in str(raw or '').split(',') if v.strip()}
            if not vals:
                continue
            for allowed, tid in _DBA_TEAM:
                if vals <= allowed:
                    # Both BUs named = no narrowing to push; leave it to the post-filter.
                    team_id = tid if team_id in (None, tid) else None
                    break
        elif col == 'agent':
            v = str(raw or '').strip()
            # A SET of agents cannot become the pool's single `agent_name`; the post-filter
            # handles it. Only an unambiguous single value pushes down.
            if v and ',' not in v:
                agent = v
    return team_id, agent


def _group_dba_team(group):
    """The team a top-level `or` group pins, or None.

    Recognises ONLY the exact shape "every child is a `dba eq <brand>` leaf" β€” the group the
    condition builder emits for a multi-value BU condition, and the one `perm_migrate` writes.
    Every OTHER group returns None and is left entirely to the post-filter: a group mixing `dba`
    with another column, or containing a nested group, does not pin a BU on its own, and
    guessing that it does would build the pool from the wrong book. Narrow by construction β€”
    the pushdown may only ever be an OPTIMISATION of a constraint the filter already expresses.
    """
    if group.get('conj') != 'or':
        return None
    children = group.get('children') or []
    if not children:
        return None
    vals = set()
    for c in children:
        if (not isinstance(c, dict) or isinstance(c.get('children'), list)
                or c.get('colId') != 'dba' or c.get('op') != 'eq'):
            return None
        v = str(c.get('value') or '').strip().lower()
        if not v:
            return None
        vals.add(v)
    for allowed, tid in _DBA_TEAM:
        if vals <= allowed:
            return tid
    return None