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
|