diff --git "a/platform/core/perm_scope.py" "b/platform/core/perm_scope.py" --- "a/platform/core/perm_scope.py" +++ "b/platform/core/perm_scope.py" @@ -1,1619 +1,1926 @@ -"""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'] = {'': {'access': bool, - 'filter': {'conj'?: 'and'|'or', 'nodes': [...]} | None, - 'hiddenFields': ['', ...]}} - 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: []` 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 - - -def may_metrics(user, module): - """May this account build and receive METRIC columns — lookback measures — on `module`? - - ⭐⭐ W38-T19 — A CAPABILITY, NOT A SECOND SPELLING OF ACCESS, and the distinction is the - ticket. A rollup aggregates the CHILDREN a row is linked to; a Metric answers *"this number, - over this window"* against a governed topic with no relation at all (CLAUDE.md standing rule - 9). So it reads the book behind the rows rather than the rows: an account can be exactly the - right person to see a customer list and the wrong one to mint 12-month revenue over it. Two - decisions, two controls. - - ⛔ ABSENCE GRANTS, AND THE ASYMMETRY WITH `may_access` IS DELIBERATE RATHER THAN AN - OVERSIGHT TO TIDY. `may_access` reads migrated-and-undeclared as DENY, which is right there - because every save writes an entry for every governed database — absence means an - administrator decided. No `metrics` KEY was STORABLE before this ticket, so every migrated - record in every tenant carries none, and reading that absence as DENY would revoke Metrics - for everybody on the day this shipped with nobody having decided anything. That is the exact - failure `routes_admin.get_perms` records twice already (the `ut_*` default and the surface - default), one field further down the same entry. **Only an explicit `metrics: false` - refuses.** - - ⚠ IT DOES NOT RE-ASK ACCESS. Every caller is already behind the access wall (`module_gate`, - `may_read`, `session.require`), and folding admission in here would give a denial two - possible causes with one answer — the shape that makes a permission bug take an afternoon. - """ - if perms.is_admin(user): - return True - e = entry(user, module) - return not (isinstance(e, dict) and e.get('metrics') is False) - - -def nav_may_open(user, key, st=None): - """May this account SEE `key` — in the nav, and at the route gate? The ADMISSION question. - - ⛔⛔ WHY THIS EXISTS: THE EDITOR'S DECISION REACHED THE READ DOOR AND NOTHING ELSE. Measured - on live 2026-08-18, on a real account. An administrator ticked *Odoo products* for Naomi in - Manage user and saved; the record stored `perms.product_data.access = True` and - `may_access()` agreed. **The database never appeared.** `perms.nav_pages` and - `deps.Session.require` both ask `perms.may_open` — the LEGACY `user['modules']` array — which - still read `['sales', 'customers', 'products']`, and `products` is the key of the ARCHIVED - *SKU* module, not of `product_data`. So `nav_pages` answered `['customer_data']` and the - route would have 403'd her even by URL. Two permission systems, the editor writing one and - every DOOR reading the other ([[two-permission-systems-one-armed]]). - - ⭐ THE ASYMMETRY IS THE WHOLE DESIGN, and it is not the same rule twice: - - 1. an admin sees everything (break-glass, as everywhere else); - 2. an EXPLICIT `access: false` DENIES, on any key — an administrator unticking a box must - take the row off the nav, and before this it did not; - 3. a `ut_*` key with no explicit deny defers to `user_tables.may_open` — creator, admin or - a `core.shares` grant — and **an `access: true` entry may NEVER widen past it**. The - editor must not become a way to hand somebody another user's private table; - 4. a REGISTRY TOPIC with an explicit entry takes that entry, grant included. Here the - editor IS the authority: `_clean_perms` writes an entry for every governed topic on - every save, so an entry means an administrator decided; - 5. anything else — a module this editor does not govern — falls through to - `perms.may_open`, UNCHANGED. - - ⛔ LEG 5 IS LOAD-BEARING AND IT IS WHY `may_access` COULD NOT SIMPLY BE CALLED HERE. - `may_access` reads migrated-and-undeclared as DENY, which is correct for a governed topic and - catastrophic for the nav: Naomi's block declares the ten governed keys and nothing else, so - `sales` would have gone from visible to denied — an outage dressed as a permission fix. - """ - if perms.is_admin(user): - return True - e = entry(user, key) - if e is not None and not bool(e.get('access', True)): - return False - if str(key or '').startswith(_ut().KEY_PREFIX): - return bool(_ut().may_open(key, (user or {}).get('username'), False, st=st)) - if e is not None: - return bool(e.get('access', True)) - return perms.may_open(user, key) - - -def assistant_entry(user, module): - """The explicit database grant an Assistant snapshot may rely on, else ``None``. - - The interactive application retains an admin break-glass path and a temporary legacy-grant - compatibility path. Neither is an answer at the Assistant's app-stored data boundary: - that reader must be able to name the migrated grant that admitted a database. In particular, - a store-outage admin identity with no ``perms`` document is not an unresolved permission that - may be widened into data access. - """ - e = entry(user, module) - if (not is_migrated(user) or not isinstance(e, dict) - or not bool(e.get('access', True)) or not may_access(user, module)): - return None - return e - - -# ── FIELDS ──────────────────────────────────────────────────────────────────────────────────�� -#: ⭐⭐ W38-T16 — THE MARKER THAT SAYS "THIS COLUMN IS GOVERNED BY A GRANT", stamped once by the -#: door that creates a shared column (`routes_tables.patch_shared_cell`) and never flipped by any -#: door afterwards. It is the `perms_v` idiom one object down, and it is here for the reason that -#: marker exists: **absence must not be read as a decision.** -#: -#: ⛔⛔ WHY A MARKER AND NOT "DOES A GRANT RECORD EXIST". Every tenant-wide column shipped before -#: this ticket carries no `field` grant record, because none was STORABLE — `shares.KINDS` had -#: three members. Reading that absence as "granted to nobody" would blank every existing shared -#: column for every account the moment this arms, which is not a permission fix, it is an outage -#: (`may_read` leg 3 carries the same argument for `ut_*` keys, in the same words). -#: -#: ⛔⛔ AND IT IS WHAT MAKES THE WALL FAIL **CLOSED**. The polarity here is the opposite of every -#: other grant check in this repo: a field share is a GRANT, so "no grant" has to mean HIDDEN or -#: the wall does nothing — but "no grant record" also describes a legacy column and a store that -#: could not be read. The marker separates the three: `granted` on the column means an explicit -#: decision was taken, so an unreadable registry hides it; no marker means legacy, so nothing -#: changes. Without it, one unreadable read of `object_shares` would publish every governed -#: column to the whole tenant, silently ([[a-guard-for-the-dangerous-case]]). -#: -#: ⚠ IT IS NOT `shared_overlay`'s `"shared": True` AND MUST NOT BE CONFUSED WITH IT. That flag is -#: written and never read (D-414) — `is_shared` is a dict-membership test — so it is not evidence -#: of anything. This one is read HERE, on every assembly, and the only writer is the create door. -FIELD_GRANT_MARK = 'granted' - - -def granted_field_keys(fields): - """Every column in `fields` that declares itself GOVERNED by a `shares` grant. - - ⭐ THE CHEAP HALF OF THE WALL, AND IT IS WHY THE WALL COSTS NOTHING FOR ALMOST EVERYBODY. It - is a scan of dicts already in memory, so a database with no governed column reaches no store - at all and `hidden_keys` behaves exactly as it did before this ticket. The registry is only - opened once this answers non-empty. - """ - return {str(f['key']) for f in (fields or ()) - if isinstance(f, dict) and f.get('key') - and f.get(FIELD_GRANT_MARK) is True} - - -def field_grant_hidden(user, table_key, fields, st=None): - """The governed columns of `table_key` this principal holds NO grant on — C1's per-FIELD wall. - - ⭐⭐ W38-T16 / R7 / R8 — THE THIRD WALL, AND IT COMPOSES ALONGSIDE THE OTHER TWO RATHER THAN - INSIDE THEM. `may_open` answers *IF* you reach a database; `may_read`'s stored `access: false` - overlay may REVOKE one; this answers *WHICH COLUMNS* of it you receive. It is deliberately not - threaded through that overlay: the overlay is **deny-only** by its own docstring (*"it may - revoke a database the wall below would admit, and it may never grant one that wall refuses"*) - and a field share is a GRANT — the widening direction. Merging them would give the codebase a - second idea of who grants what, which is the failure `may_open`'s own note is the record of. - It composes the way `may_open`'s grant leg does: additively, last, fail-closed. - - ⛔ AN ADMIN IS NOT WALLED (break-glass, as everywhere else) and neither is the column's OWNER — - `shares.role_for` answers `'owner'` for the creator, so a person cannot lose their own column - by forgetting to share it with themselves. - - ⚠ `st` IS THE TENANT HANDLE AND ITS ABSENCE IS SAFE HERE, unlike everywhere else. A caller - that cannot lend one reads the module-default bucket; on any tenant but #0 that finds no - grant, and no grant on a MARKED column means HIDDEN. So a door that has not learned to thread - `st` under-shares rather than over-shares, and the symptom is a grantee who cannot see their - column — visible, reportable, and the opposite of a leak. - """ - if perms.is_admin(user): - return set() - marked = granted_field_keys(fields) - if not marked: - return set() - uname = str((user or {}).get('username') or '').strip().lower() - try: - import core.shares as shares - except Exception: # noqa: BLE001 - return set(marked) - if not uname: - # No principal, and a marked column is an explicit decision: nobody is not somebody. - return set(marked) - # ⭐ THE CREATOR IS READ OFF THE COLUMN, NOT OUT OF THE REGISTRY, AND THAT IS NOT A SECOND - # AUTHORITY. `createdBy` is ALREADY what decides who may DELETE a shared column (R8 / D-172, - # `routes_tables.delete_shared_field`) and who may CLAIM it (`routes_shares._owns_object`); - # asking the same field here keeps one answer to "whose column is this" across all three. - # ⛔ IT IS ALSO THE ONLY THING THAT SURVIVES A CLAIM THAT NEVER LANDED. The create door writes - # the definition first and the grant record second, on purpose — so the window where a column - # is marked and unclaimed exists, and without this line its own author would be walled out of - # the column they just made, permanently and with no way to fix it but an admin. - mine = {str(f['key']) for f in (fields or ()) - if isinstance(f, dict) and f.get('key') and str(f['key']) in marked - and str(f.get('createdBy') or '').strip().lower() == uname} - hide = set() - for key in marked - mine: - try: - oid = shares.field_oid(table_key, key) - role = shares.role_for('field', oid, uname, is_admin=False, st=st) - except Exception: # noqa: BLE001 - role = None - if role is None: - hide.add(key) - return hide - - -def _measure_bound_keys(hidden, fields): - """Every column BOUND to a measure whose `measure_` pseudo-field is in `hidden`. - - ⭐ THE PREFIX IS IMPORTED, NEVER SPELLED. `aios_grid.MEASURE_FIELD_PREFIX` is the one - constant the client's `createField`, the host's `clean_measure_field` and now this wall all - key off; a literal "measure_" here would be the third copy, and the first to drift - [[constant-two-features-share]]. The import is lazy and function-local, which is the - established shape in this layer (`core/grid_events.py`, `core/user_tables.py` both do it) and - keeps `core` from pulling the grid module at import time. - - ⚠ CHEAP FIRST. Called only once `hidden` is already non-empty, and it returns on an empty - `wanted` before touching `fields` — so a wall with no metric tick costs one set - comprehension over a handful of strings, on a function that runs at every grid door. - - ⛔ A FAILED IMPORT HIDES NOTHING EXTRA RATHER THAN TAKING THE DOOR DOWN, matching - `field_grant_hidden`'s treatment of an unreachable `core.shares`. The direction is stated - because it is the unsafe one: this leg only ever WIDENS the hidden set, so losing it - under-hides — visible, reportable, and the symptom is a metric column that should have - been walled, not a database that will not open. - """ - try: - import aios_grid as _agm - prefix = _agm.MEASURE_FIELD_PREFIX - except Exception: # noqa: BLE001 - return set() - wanted = {h[len(prefix):] for h in hidden - if isinstance(h, str) and h.startswith(prefix) and len(h) > len(prefix)} - if not wanted: - return set() - out = set() - for f in fields or (): - if not isinstance(f, dict) or not f.get('key'): - continue - spec = f.get('measure') - if isinstance(spec, dict) and str(spec.get('key') or '') in wanted: - out.add(str(f['key'])) - return out - - -def hidden_keys(user, module, fields, st=None): - """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. - - ⭐⭐ W36-T21 — AND A ROLLUP IS THE SAME LEAK ONE MECHANISM OVER, which matters now that this - closure runs on the `ut_*` databases rather than only on the two registry topics. A rollup - names a LINK COLUMN OF THIS TABLE (`rollup.link`) and aggregates a field on the table that - link points at — so `ut_odoo_customers.ar_outstanding` is *"sum `residual` over the invoices - this row links to"*. Hide `invoices` and keep `ar_outstanding` and the reader still learns - what the hidden link contains, in aggregate; the three outcomes are exactly the three the - formula argument above enumerates, and only "strip both" is coherent. Verified against the - real declarations (`odoo_relational.customer_fields`) rather than assumed: every rollup there - is either `{'link': , 'field': }` - or a `source` topic aggregate, so `rollup.link` is the ONE same-table reference a rollup makes - and `rollup.field` is deliberately not treated as one — it names another database's column, - which has its own wall. - """ - if perms.is_admin(user): - return frozenset() - # ⭐⭐ W38-T16 — TWO SOURCES OF HIDING, ONE CLOSURE, AND THAT UNION IS THE WHOLE INTEGRATION. - # - # The administrator's `hiddenFields` and a field's own grant answer different questions and - # both end in the same place: a key this reader may not receive. Seeded together HERE, before - # the fixpoint, so the transitive argument above covers the new source unchanged — a formula - # (or a rollup) over a column this reader was not granted comes out with it, or the value - # leaks wearing the derived column's name. - # - # ⛔ AND THIS FUNCTION IS THE INSERTION POINT RATHER THAN `_scoped`, WHICH IS WHAT THE TICKET - # ASSUMED. `_scoped` is C1's evaluator and reaches C1's two doors; **the product reads through - # neither of them.** Every grid door calls THIS: `routes_customers:196`, `routes_products:345`, - # `routes_odoo_tables:920/1119`, `routes_tables._ut_hidden/_ut_field_wall`, `routes_slack:190`, - # `routes_grid:57/69/787` — and `grid_events` walls WRITES off the same set through - # `EventCtx.hidden_keys`. One evaluator, every door, both wires, read and write. - hidden = set(field_grant_hidden(user, module, fields, st=st)) - e = entry(user, module) - if e: - hidden |= {str(k) for k in (e.get('hiddenFields') or ()) if k} - if not hidden: - return frozenset() - # ⭐⭐ W40-T04 / I13 — A THIRD SOURCE, SEEDED BEFORE THE FIXPOINT FOR THE SAME REASON THE - # OTHER TWO ARE, AND IT IS WHAT MAKES THE NEW CHECKBOX ENFORCE ANYTHING. - # - # The permission editor now offers one `measure_`-namespaced PSEUDO-field per bound measure - # (`routes_admin._metric_fields`), so an administrator can tick "Metric - Gross margin $" and - # the wall stores `hiddenFields: ['measure_margin']`. But the COLUMNS that carry that number - # are keyed `measure__` by `CustomerGrid.createField` — `measure_margin_a1b2`, - # not `measure_margin` — and the seeding above compares KEYS. Without this line the box - # ticks, the record saves, every gate stays green and the reader keeps every gross-margin - # column on the grid. I13's own words are *"so a user can check the ones the permissioning is - # LIMITED TO"*; a control that limits nothing is the failure - # `verify_ui.py::metrics_toggle_has_a_mount_site` exists because of. - # - # ⭐ SO THE JOIN IS ON THE BINDING, NOT THE KEY: a hidden `measure_` hides every column - # whose `measure.key` IS ``, which is the same spec `aios_grid.clean_measure_field` - # wrote and `measure_fields_of` reads. One measure, however many windows a user minted over - # it, all walled by one tick. - # - # ⭐ ADDITIVE, NEVER SUBSTITUTIVE. The pseudo-key stays in `hidden` on its own account, so a - # real column that happens to be keyed exactly `measure_margin` is hidden exactly as before - # and nothing depends on the pseudo-field existing. - # - # ⛔ AND IT GOES HERE, ABOVE THE FIXPOINT, so the transitive argument this docstring already - # makes covers it unchanged: a FORMULA over a walled metric column, or a ROLLUP whose link is - # one, comes out with it. Seeded after the loop it would leak through the derived column, - # which is outcome two of the three the docstring enumerates. - hidden |= _measure_bound_keys(hidden, fields) - - 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()} - link = (f.get('rollup') or {}).get('link') if isinstance(f.get('rollup'), dict) else None - if isinstance(link, str) and link.strip(): - refs.setdefault(f['key'], set()).add(link.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, st=None): - """`fields` minus the hidden closure. Order preserved — the column order is the user's. - - ⚠ W38-T16 — `st` MUST MATCH WHAT ITS CALLER PASSED TO `hidden_keys`, and that is not a style - note. This recomputes the closure, and since the field-grant leg under-hides without a tenant - handle, a caller that lends one to `hidden_keys` and not to this would narrow the FIELD LIST - by MORE than it stripped from the ROWS — the value left sitting in the payload under a column - nobody can see, which is exactly the half-wall `strip_row`'s note exists to forbid. - """ - hide = hidden_keys(user, module, fields, st=st) - 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 assistant_visible_fields(fields, user, module): - """Visible closure under one explicit Assistant grant, including for an admin caller.""" - e = assistant_entry(user, module) - if e is None: - return [] - # `hidden_keys` deliberately preserves the interactive admin break-glass behaviour. The - # Assistant uses its explicit grant instead, but shares the same transitive formula closure. - hidden = {str(k) for k in (e.get('hiddenFields') or ()) if k} - if not hidden: - return list(fields or ()) - # ⛔⛔ THE SAME LEAK ONE DOOR OVER, AND IT IS THE SAME `hiddenFields`. `assistant_entry` - # returns the very dict `entry()` does, so once the permission editor can store - # `measure_margin` the Assistant's grant carries it too — and comparing KEYS would leave - # `measure_gross_profit_a1b2` in the snapshot this reader is handed. Seeded here for the - # identical reason and at the identical point as in `hidden_keys`: before the fixpoint, so a - # formula over a walled metric column comes out with it. - hidden |= _measure_bound_keys(hidden, fields) - - refs = {} - for field in fields or (): - if not isinstance(field, dict) or not field.get('key'): - continue - expr = field.get('formula') - if isinstance(expr, str) and expr: - refs[field['key']] = {match.strip() for match in _FORMULA_REF.findall(expr) - if match.strip()} - for _ in range(12): - 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 [field for field in (fields or ()) - if not (isinstance(field, dict) and field.get('key') in hidden)] - - -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} - - -def visible_overlays(overlays, user, module, fields, st=None): - """The overlay CELL MAP minus every column this reader may not receive — D-427. - - ⛔⛔ THE THIRD WIRE, AND IT CARRIES THE RAW VALUE. `strip_row`'s own docstring makes the - argument for two wires: *"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."* There is a THIRD. `/workspace?scope=product` serves - `workspace["overlays"] = g["ws"].get("overlays")` verbatim — the persisted user/tenant - stratum, keyed `{row id: {field key: value}}` — and that assignment never asks who is - reading. So an administrator hides `first_cost`, the grid dutifully drops the column and the - cell, and the same number is still sitting in the workspace payload under the same key. The - wall holds on two wires out of three, which is not a wall. - - ⭐ WHY THIS LIVES HERE RATHER THAN AT THE DOORS. `hidden_keys` is already the ONE evaluator - every grid door calls, and the three leaking assignments are one line each in three files. - Putting the narrowing beside `strip_row` means the fix at each door is a single call to the - module that already owns the question, instead of a fourth place that decides what a reader - may see. A second idea of who hides what is the failure `may_open`'s own note records. - - ⭐ SAME ARGUMENT ORDER AS `visible_fields`, deliberately: a door that already narrows its - field list has the four values to hand, and `st` MUST be the same handle it passed there. - The field-grant leg under-hides without a tenant handle, so a door that lends one to - `visible_fields` and not to this would strip the COLUMN while leaving the overlay VALUE — - the half-wall this function exists to close, re-created by the fix for it. - - ⚠ NON-DESTRUCTIVE. A new dict is built rather than the caller's mutated, because the same - overlay object is read again by `rows_from_pool` on the assembly path; and the input is - returned untouched when nothing is hidden, so a database with no wall pays one set test. - """ - hide = hidden_keys(user, module, fields, st=st) - if not hide or not isinstance(overlays, dict): - return overlays - return {rid: strip_row(cells, hide) if isinstance(cells, dict) else cells - for rid, cells in overlays.items()} - - -# ── ROWS ───��───────────────────────────────────────────────────────────────────────────────── -def _wall_leaf_keys(nodes): - """Every `colId` a filter tree names, at any depth. Groups carry `children`; leaves do not. - - ⚠ THE SAME WALK AS `routes_admin._leaf_col_ids`, and the duplication is deliberate rather - than lazy: that one runs at the ADMIN DOOR inside the API package, this one runs in `core` - on the request path, and `core` never imports up (see `platform/ARCHITECTURE.md`). The two - are three lines each and `verify_api` asserts they answer the same set on the same tree, so - a grammar change cannot land in one and not the other. That equality is the point: the door - refuses through one walk and the wall resolves through the other, and a wall the editor - accepts but the enforcement path cannot see is the defect this whole change is about. - - ⛔ AN `rhs` COLUMN IS NOT COLLECTED, MATCHING `_leaf_col_ids` AND FAILING CLOSED. A - column-to-column leaf whose RIGHT side is a user-generated column stays unenriched, so - `is_rule_active` finds that side outside the contract, calls the rule inactive, and strict - mode turns that into a DENY. That is the safe answer, and it is the same one this door gave - before the enrichment existed. `prune_filter_to_fields` does walk `rhs`, deliberately, and - the asymmetry is the direction each function fails in: deleting a leaf WIDENS a wall, so - that one must see every column a leaf names; refusing to enrich one only narrows. - """ - found = set() - for node in nodes or (): - if not isinstance(node, dict): - continue - if isinstance(node.get('children'), list): - found |= _wall_leaf_keys(node['children']) - elif node.get('colId'): - found.add(str(node['colId'])) - return found - - -def _enrich_for_wall(tree, rows, fields, module, st): - """`(rows, fields)` — the same wall inputs, plus any USER-GENERATED column this wall names - that the SERVER can actually answer. Owner I16, the half `ut_*` got for free. - - ⭐⭐ THE DEFECT. On the two Odoo topic grids the wall runs as - `apply_row_scope(rows, user, MODULE, )` over PRE-OVERLAY rows, so a - column a user created is neither declared in that field list nor present on the row — and - `filter_eval.permits` DENIES every leaf it cannot answer. Measured four ways on the - integrated head, one leaf `{colId: , op: eq, value: West}`, two rows: - - static contract + pre-overlay rows (THE LIVE CALL SITE) -> [] every row denied - column declared + rows carrying the value -> ['1'] correct - column declared + pre-overlay rows -> [] - a DECLARED odoo column, same shape -> ['1'] the evaluator is fine - - So the administrator saves a rule, is told it saved, and that account opens an empty grid - with nothing on screen saying why. BOTH halves are needed and neither alone does anything, - which is why this function supplies both from one read. - - ⛔⛔ IT MAY ONLY EVER ADD THE ABILITY TO ANSWER — IT MUST NEVER ADMIT A ROW THE WALL WOULD - OTHERWISE REFUSE. That is why `wallable_overlay_keys` is defined as *the keys whose values - the very `cells` read below serves*, and not as "every overlay column". Merge a blank for a - key this store cannot really answer and `custom_x is not West` flips from denying every row - (undeclared leaf) to admitting every row (`'' != 'West'`), with `is empty` doing the same — - a silent widening wearing the shape of a fix. The set and the values come from ONE stratum - so they cannot disagree about what is answerable. - - ⛔ AND THE WHOLE THING IS WRAPPED FAIL-CLOSED. On ANY failure the caller's own `rows` and - `fields` come back untouched and `permits` denies exactly as it does today. A wall that - cannot be enriched is a wall that keeps refusing, never one that opens. - - ⚠ THE ORDER IS A COST DECISION, NOT A STYLE ONE. `missing` is answered from the tree and the - field list already in memory, and an empty `missing` returns BEFORE `wallable_overlay_keys` - is called — so a wall naming only declared columns (every wall that exists today) pays one - set difference and reaches no store at all. - """ - try: - from harness import filter_eval as fe - # ⛔ `tree_parts`, NEVER `tree['nodes']`. A `FilterTree` is a PAIR and a BARE LIST is also - # a legal shape (C-PERM amendment 2); a second reader of it here would answer "no leaves" - # for a wall the evaluator one line down reads perfectly well. - nodes, _conj = fe.tree_parts(tree) - leaves = _wall_leaf_keys(nodes) - if not leaves: - return rows, fields, frozenset() - declared = {str(f['key']) for f in (fields or ()) - if isinstance(f, dict) and f.get('key')} - missing = leaves - declared - if not missing: - return rows, fields, frozenset() # nothing to add — the common case, and it is free - resolvable = missing - if not resolvable: # unreachable as written: `missing` is non-empty three lines up. - # Kept as the SHAPE of the guard, because `resolvable` is narrowed AGAIN below once - # the snapshot says what is actually answerable, and that narrowing can empty it. - # A wall naming a column NOTHING here can answer still denies, which is the whole - # point of `permits`. Enrichment is not a licence to ignore an unanswerable leaf. - return rows, fields, frozenset() - import core.shared_overlay as so - import core.view_templates as vt - ws_key = vt.workspace_key(str(module or '')) - if not ws_key: - return rows, fields, frozenset() - source = list(rows or ()) - # ⚠ THE ROW'S CELL KEY IS DERIVED BY `shared_overlay`'s OWN RULE, `str(int(pid))`, and - # not by `str(pid)`. That is the one coercion the stratum has (pids are ints in the grid - # and strings in JSON), so a second spelling here would look up `'1.0'` in a document - # keyed `'1'` and merge a blank over a value that is right there. It also RAISES on a - # pid-less or non-numeric row: a `cells` call carrying one would fail the whole request - # CLOSED, i.e. disable the fix silently for every other row rather than going red, so - # such a row is simply given the blank instead. - keys = {} - for i, r in enumerate(source): - if not isinstance(r, dict): - continue - try: - keys[i] = str(int(r.get('pid'))) - except (TypeError, ValueError): - continue - # ⭐⭐ ONE READ FOR THE WHOLE PAGE, AND FOR BOTH HALVES OF THE QUESTION. `snapshot` - # returns the shared stratum's SCHEMA and its CELLS from a single `st.get`, and it - # refuses an "everything" read by signature, so the row set handed in IS the bound. - # - # ⛔⛔ THE TWO HALVES MUST COME FROM ONE SNAPSHOT, AND THIS USED TO BE TWO READS. A - # wave-40 adversarial probe drove the gap: with the second read returning an emptied - # `cells`, a blank is merged for a key the store cannot really serve, and - # `custom_x is not West` flips from denying every row to ADMITTING a row whose true - # value IS West. `is empty` does the same. Narrow under today's cache-first store and - # structurally real under a threaded server, so it is closed by construction rather - # than by being unlikely. - _defs, cells = so.snapshot(ws_key, list(keys.values()), st=st) - wallable = wallable_overlay_keys(module, st=st, defs=_defs) - resolvable = resolvable & set(wallable) - if not resolvable: - return rows, fields, frozenset() - merged, denied = [], [] - for i, r in enumerate(source): - if not isinstance(r, dict): - merged.append(r) - continue - if i not in keys: - # ⛔⛔ A ROW WHOSE `pid` DOES NOT RESOLVE GETS NOTHING MERGED, AND THAT IS THE - # WHOLE POINT. `keys` holds only indices whose pid survived `str(int(pid))`; for - # any other row the store was never asked, so its value is UNKNOWN -- a different - # thing from the legitimately blank cell a real pid with no stored value has. - # Merging `''` for it hands `is not X` and `is empty` a FABRICATION and treats it - # as ground truth: a wave-40 adversarial probe drove exactly that, admitting a - # no-pid row under `is not West`, and a non-numeric-pid row under `less than 5` - # on a numeric column -- both of which the un-enriched wall refuses. Leaving the - # key off keeps the leaf unanswerable, so `permits` denies. - # - # ⚠ Not reachable through either registered reader today: `customer_data` and - # `product_data` both emit integer Odoo ids. Fixed because the invariant this - # function states is UNCONDITIONAL, not because it happened to be reachable. - # ⛔ DENIED, not merely un-merged. Leaving the key off the row is NOT - # enough: `wall_fields` DECLARES the column, and `permits` reads a declared - # field whose key is absent from the row as BLANK -- the same fabrication one - # level down, and measured doing exactly that. The index is recorded and the - # door drops the row outright. - merged.append(r) - denied.append(i) - continue - row_cells = cells.get(keys[i]) or {} - # A COPY, never the caller's dict mutated. These rows are the pool the tenant - # runtime caches and hands to every other consumer on the request (the workspace, - # the cohorts, the measures); writing a wall's working value into them would put a - # column on a payload nobody asked for it on. - merged.append(dict(r, **{k: row_cells.get(k, '') for k in resolvable})) - return (merged, list(fields or ()) + [wallable[k] for k in resolvable], - frozenset(denied)) - except Exception: # noqa: BLE001 - return rows, fields, frozenset() - - -def apply_row_scope(rows, user, module, fields, ctx=None, st=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. - - ⭐⭐ `st` IS OWNER I16's SECOND HALF AND IT IS OPTIONAL SO THE FIRST HALF CANNOT MOVE. - *"Permission Filters must be able to filter on user-generated Fields too."* With a tenant - handle this door can resolve a user-generated column the static contract does not declare - (see `_enrich_for_wall`); WITHOUT one it is byte-identical to the wall that shipped before - this parameter existed, which is what keeps a cold process — a gate, a worker, E's sandbox — - behaving exactly as it always has. - - ⛔ PASS IT AT EVERY DOOR THAT WALLS A TOPIC GRID, OR AT NONE OF THEM. One stored wall read - through a door that lends the handle and a door that does not is one rule with two meanings, - which is worse than a uniform refusal: `allowed_pids` is the WRITE wall and `grid_assembly` - the READ one, and a user who may PATCH a row they cannot SEE is the hole - `allowed_pids`' own docstring exists to close. - """ - 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 - if st is not None: - # ⛔⛔ THE ENRICHMENT ANSWERS THE WALL AND MUST NEVER REACH A CALLER. It merges a - # column's value onto a COPY of each row so `permits` can evaluate a leaf naming it; - # returning those copies hands every consumer a column the caller's own field list - # does not declare. Measured in wave-40 QA against `core/script_sandbox.py`, which - # passes `scoped_table()`'s rows straight to a user-authored script: - # - # scoped_fields() declared keys: ['dba'] - # scoped_table() returned rows : [{'pid': 2, 'dba': 'Fisch', - # 'custom_region_qa': 'TOP-SECRET-VALUE'}] - # - # reachable by any ordinary session through `POST /script-views/{id}/run`. And the - # field-grant hide could never have caught it: `field_grant_hidden` can only mark a - # key already present in the `fields` it is handed, and this key never is. - # - # ⭐ So the decision is made on the enriched copy and the ORIGINAL row is what - # survives. `_enrich_for_wall` returns one entry per input row, in order, which is - # what makes the pairing sound; the copy is ONLY ever an argument to `permits`. - # ⚠ A length disagreement means the enrichment did not do what it promises, so the - # fall-through is the UN-enriched wall, which denies. Never the enriched rows. - # ⛔ MATERIALISED BEFORE THE ENRICHMENT SEES IT. `rows` may be any iterable, and - # `_enrich_for_wall` has three early-return paths that hand it straight back -- so a - # GENERATOR reached `len(judged)` and raised `TypeError: object of type 'generator' has - # no len()` on the common wall (one naming only declared columns), or, when enrichment - # did run, left the outer generator exhausted and the length check comparing against - # zero. Both fail closed, but one is a crash and the other a silent empty answer. One - # `list()` removes the class. Found by a wave-40 adversarial probe. - source = list(rows or ()) - judged, wall_fields, denied = _enrich_for_wall(tree, source, fields, module, st) - if len(judged) == len(source): - return [orig for i, (orig, seen) in enumerate(zip(source, judged)) - if i not in denied and fe.permits(tree, seen, wall_fields, ctx)] - return [r for r in (rows or ()) if fe.permits(tree, r, fields, ctx)] - - -def assistant_apply_row_scope(rows, user, module, fields, ctx=None, st=None): - """Apply an explicit Assistant grant's permanent filter without an admin bypass. - - `st` carries the same meaning it does on `apply_row_scope`, for the same reason: an Assistant - grant is stored by the same editor, against the same vocabulary, and a wall that means one - thing on the grid and another in the Analyst's answer is two walls. - """ - e = assistant_entry(user, module) - if e is None: - return [] - tree = e.get('filter') - if not tree: - return list(rows or ()) - from harness import filter_eval as fe - if st is not None: - # Same rule as `apply_row_scope`: judge on the copy, return the ORIGINAL. See the block - # there for the measured leak this prevents. - # ⛔ MATERIALISED BEFORE THE ENRICHMENT SEES IT. `rows` may be any iterable, and - # `_enrich_for_wall` has three early-return paths that hand it straight back -- so a - # GENERATOR reached `len(judged)` and raised `TypeError: object of type 'generator' has - # no len()` on the common wall (one naming only declared columns), or, when enrichment - # did run, left the outer generator exhausted and the length check comparing against - # zero. Both fail closed, but one is a crash and the other a silent empty answer. One - # `list()` removes the class. Found by a wave-40 adversarial probe. - source = list(rows or ()) - judged, wall_fields, denied = _enrich_for_wall(tree, source, fields, module, st) - if len(judged) == len(source): - return [orig for i, (orig, seen) in enumerate(zip(source, judged)) - if i not in denied and fe.permits(tree, seen, wall_fields, ctx)] - return [row for row in (rows or ()) if fe.permits(tree, row, fields, ctx)] - - -def validate_assistant_filter(tree, fields): - """Return a strict, detached Assistant filter tree or raise ``ValueError``. - - The display filter cleaner is intentionally permissive: it drops a stale column or leaves an - inactive condition alone so an old saved view can still open. An Assistant data reader cannot - inherit that behaviour — dropping a predicate turns a request for a subset into a wider read. - This validator therefore admits only visible field operands that the existing evaluator can - answer from one stored row. Cohort, measure and rank conditions need separate materialised - set resolvers and are refused here rather than guessed. - """ - if tree is None: - return None - from harness import filter_eval as fe - - by_key = {str(field.get('key')): field for field in (fields or ()) - if isinstance(field, dict) and field.get('key')} - if not by_key: - raise ValueError('assistant filter has no visible field contract') - - def _leaf(raw): - if not isinstance(raw, dict): - raise ValueError('assistant filter leaf must be an object') - allowed = {'id', 'colId', 'op', 'value', 'value2', 'rhs'} - if set(raw) - allowed: - raise ValueError('assistant filter carries an unsupported operand') - col = raw.get('colId') - op = raw.get('op') - if not isinstance(col, str) or col not in by_key: - raise ValueError('assistant filter names an unknown or hidden field') - if (not isinstance(op, str) or op not in fe.FILTER_OPS or op in fe.RANK_OPS - or op in {'between', 'within'} or col == fe.COHORT_FIELD): - raise ValueError('assistant filter uses an unsupported operator') - rhs = raw.get('rhs') - if rhs is not None: - if (not isinstance(rhs, dict) or rhs.get('kind') != 'field' - or set(rhs) - {'kind', 'colId'} - or not isinstance(rhs.get('colId'), str) - or rhs['colId'] not in by_key): - raise ValueError('assistant filter names an unknown or hidden right-hand field') - out = {name: raw[name] for name in ('id', 'colId', 'op', 'value', 'value2') - if name in raw} - if rhs is not None: - out['rhs'] = {'kind': rhs.get('kind'), 'colId': rhs['colId']} - if not fe.is_rule_active(out, by_key): - raise ValueError('assistant filter is inactive or unanswerable') - return out - - def _node(raw): - if not isinstance(raw, dict): - raise ValueError('assistant filter node must be an object') - if 'children' not in raw: - return _leaf(raw) - if set(raw) - {'conj', 'children'}: - raise ValueError('assistant filter group carries an unsupported operand') - children = raw.get('children') - if raw.get('conj') not in ('and', 'or') or not isinstance(children, list) or not children: - raise ValueError('assistant filter group must be a non-empty and/or group') - return {'conj': raw['conj'], 'children': [_node(child) for child in children]} - - if isinstance(tree, list): - if not tree: - raise ValueError('assistant filter list must not be empty') - return {'conj': 'and', 'nodes': [_node(node) for node in tree]} - if not isinstance(tree, dict) or set(tree) - {'conj', 'nodes'}: - raise ValueError('assistant filters must be a tree with nodes') - nodes = tree.get('nodes') - if tree.get('conj') not in ('and', 'or') or not isinstance(nodes, list) or not nodes: - raise ValueError('assistant filter tree must be a non-empty and/or tree') - return {'conj': tree['conj'], 'nodes': [_node(node) for node in nodes]} - - -# ── USER-GENERATED COLUMNS + THE FILTER CASCADE (W40-T05 / owner I16) ──────────────────────── -def user_generated_fields(module, st=None): - """Every USER-CREATED column of `module`, across EVERY stratum -- or `None` when that - cannot be established. - - ⭐⭐ OWNER I16 — *"Permission Filters must be able to filter on user-generated Fields too. - If the field is deleted, its permission filter goes with it."* The permission editor built - its pickers from the STATIC contract (`aios_grid.FIELDS`, the product JSON), so a column a - USER made was invisible to the admin choosing what to filter on. This is the half that finds - them; `prune_filter_to_fields` below is the half that lets one go. - - ⛔⛔ `None` IS NOT `[]`, AND THE DIFFERENCE IS A SILENTLY WIDENED WALL. `[]` means - "resolved: this database has no user columns". `None` means "the vocabulary could not be - read". The cascade prunes a filter leaf naming a column that is NOT in this list, so a - DEGRADED answer would DELETE a live permission rule the moment the store was busy — - permanently, silently, and in the widening direction. Every unresolvable path therefore - answers `None` and every caller declines to prune on it. This is the GET/PUT skew - `routes_admin._clean_perms`' metric-tick note records one door over, except that there the - failure mode was a REFUSAL and here it would be a REVOCATION. - - ⚠ THE UNION OF EVERY STRATUM, DELIBERATELY. `_table_workspace` is - `{username: {views, fields, overlays}, '__shared__': {...}}` and a column lives in exactly - one of them. `_module_fields`' own docstring says this list is *"the admin choosing what to - hide"* and the SUBJECT USER IS NOT A PARAMETER OF IT, so a per-user read would make a column - unpickable for the very user who owns it. - - ⛔⛔ AND THE TENANT-WIDE STRATUM MOVED OUT OF THAT DOCUMENT, WHICH IS HOW "EVERY STRATUM" - STOPPED BEING TRUE. `core/shared_overlay.py`'s own RESIDENCY note says it: a SHARED column - lives in `_table_workspace__shared`, *"its own bucket, beside the per-user one — never - a `__shared__` member"*, and `field_permissions.promote_field` POPS the definition out of - the creator's stratum once it is promoted. So the loop below, reading one document, saw - exactly the columns nobody had shared. Measured on the live tenant: 21 of the 22 custom - columns on these two grids carry `source: "overlay", shared: true` — i.e. the function - returned the ONE column it was least useful for. - - ⛔ THE CONSEQUENCE WAS NOT A MISSING PICKER ROW, IT WAS A SILENT REVOCATION. This list is - also `routes_admin._prunable_vocabulary`'s answer, and `prune_filter_to_fields` DELETES a - leaf naming a column outside it. A wall stored against a shared column would therefore have - had its leaf pruned on the next read of the record — permanently, silently, and in the - widening direction, which is the exact failure the `None`-is-not-`[]` note above exists to - prevent, arriving through the door this function opens. - - ⚠ A SHARED READ THAT RAISES ANSWERS `None`, LIKE THE PER-USER ONE; an ABSENT shared bucket - is "nothing has been shared here yet" and does NOT poison the answer. Both arms are - deliberate: the first keeps the vocabulary honest under contention, and the second is what - stops a tenant that has never shared a column from losing the per-user half of I16. - - ⛔ THE BUCKET NAME IS `view_templates.workspace_key`, NEVER SPELLED. `customer_data`'s bucket - is `customer_table_workspace` — the module key is NOT the storage key — and `_WS_KEYS` is - already the one place that mapping lives ([[one-question-two-normalizers]]). - - ⛔ THE SHAPE COMES FROM `aios_grid.fields_from_workspace`, NEVER HAND-BUILT. That is the - function the GRID overlays a saved stratum with, so a column reaches the permission editor - described exactly as the user sees it — including `filterable: False` on a `measure_` column, - whose value is host-computed into `derived` and never sits on a row. A hand-shaped dict here - would be a second idea of what a field is, and the first thing it would lose is that flag. - `fields_base=[]` makes the base loop a no-op, so what comes back is the SAVED stratum alone. - - ⚠ `scope_key=None` DROPS A COHORT-SCOPED COLUMN, AND THAT IS THE ANSWER RATHER THAN A GAP. - `grid_events` writes `scope: 'cohort'` on a column created from the Cohort surface, and such - a column is not on the Customer grid's rows at all. Offering it to the permanent filter would - admit a leaf that can only ever DENY every row — the exact reason `_metric_fields` marks a - metric pseudo-field unfilterable. The narrow read is the honest one here. - """ - if st is None: - return None - try: - import aios_grid - import core.shared_overlay as shared_overlay - import core.view_templates as view_templates - except Exception: # noqa: BLE001 - return None - bucket = view_templates.workspace_key(str(module or '')) - if not bucket: - return None # a SURFACE, or nothing this layer knows as a table - try: - doc = st.get(bucket) - except Exception: # noqa: BLE001 - return None - if not isinstance(doc, dict): - # ⛔ AN ABSENT BUCKET IS `None`, NOT `[]`. A store that cannot answer and a database - # nobody has opened are indistinguishable from here, and only one of them is safe to - # prune against. Declining costs nothing real: a workspace with no bucket has no column - # to have deleted either. - return None - # ⛔ READ THROUGH `st` RATHER THAN `shared_overlay.fields()`, AND ONLY THE NAME COMES FROM - # THAT MODULE. `shared_overlay._read` swallows every exception and answers `{}`, so an - # unreadable store would be indistinguishable from "nothing is shared" — which is precisely - # the `None`-collapsed-into-`[]` this function refuses everywhere else. `bucket()` is still - # the ONE spelling of the key, so there is no second naming convention to get wrong. - try: - shared_doc = st.get(shared_overlay.bucket(bucket)) - except Exception: # noqa: BLE001 - return None - strata = list(doc.values()) - if isinstance(shared_doc, dict): - strata.append(shared_doc) - out, seen = [], set() - for blob in strata: - if not isinstance(blob, dict): - continue - saved = blob.get('fields') - if not isinstance(saved, dict) or not saved: - continue - try: - got = aios_grid.fields_from_workspace({'fields': saved}, fields_base=[]) - except Exception: # noqa: BLE001 - # One malformed stratum must not make the whole vocabulary unresolvable — that would - # turn a bad saved field into a frozen cascade. Skip it; the other strata still count. - continue - for f in got or (): - key = f.get('key') if isinstance(f, dict) else None - if not key or key in seen: - continue - seen.add(key) - out.append(f) - return out - - -def wallable_overlay_keys(module, st=None, defs=None): - """`{key: Field}` — the user-generated columns of `module` a PERMANENT FILTER can be - evaluated against on the server. Owner I16, narrowed to what is true rather than to what is - offered. - - ⭐⭐ THE DEFINITION IS "WHOSE VALUES `shared_overlay.cells` SERVES", AND EVERY OTHER PROPERTY - FOLLOWS FROM IT. `_enrich_for_wall` merges these keys onto the rows from exactly one read of - exactly this bucket, so defining the set any other way would let it name a column whose value - the merge cannot supply — and a blank merged for a column the store cannot really answer - turns `is not X` and `is empty` from "denies every row" into "admits every row". The set and - the values therefore come from ONE stratum, by construction rather than by care. - - ⛔ SO A `formula`, `created_time` OR `measure_*` COLUMN CAN NEVER BE IN HERE, AND IT IS - `aios_grid.fields_from_workspace` THAT SAYS SO RATHER THAN A LIST OF TYPE NAMES. That is the - normaliser the GRID itself overlays a saved stratum with: it emits the read-only user pair - and the measure columns as `source: 'odoo', derived: True` (their values are computed in the - BROWSER, or from the measure catalogue, and never sit on a stored row) and an editable - overlay column as `source: 'overlay'` with no `derived` at all. Testing the two flags is - testing the grid's own declaration; a hand-built type list here would be a second idea of - what a field is, and the first thing it would lose is the next read-only kind somebody adds. - - ⛔ AND A PER-USER PRIVATE OVERLAY COLUMN IS OUT TOO, which is the part that looks like a gap - and is not. Its values ARE server-readable (`[username]['overlays']`), but they are - readable only for the SUBJECT of the wall — an account that may edit that column freely, and - would therefore be one cell edit away from walking out of its own permission wall. The admin - who wrote the rule cannot even see the values. `routes_admin._row_wall_blind_keys` keeps - refusing those at the write door, where a person is present to choose a shared column - instead. - - ⚠ `{}` ON ANY FAILURE, NEVER `None` AND NEVER A PARTIAL SET. This is read by a wall, and the - only safe degraded answer for a wall is "I can answer nothing extra" — which leaves `permits` - denying an unresolvable leaf exactly as it does today. A half-resolved set could ADMIT a row, - which is the one direction this must never fail in. - - ⛔ AND IT ANSWERS `{}` FOR A `ut_*` DATABASE — DELIBERATELY, NOT BY ACCIDENT OF THE KEY. The - two topic grids name their shared stratum off the WORKSPACE key - (`customer_table_workspace__shared`), while a user table names its own off the TABLE key - (`routes_tables._ut_shared_fields` reads `shared_overlay.fields(table_key)`, i.e. - `ut_leads__shared`, not `ut_leads_table_workspace__shared`). Deriving from `workspace_key` - therefore finds nothing on a `ut_*` key, and that is the right answer TODAY rather than a - gap to paper over: a `ut_*` wall is validated by `routes_admin._module_fields` against the - database's own stored DEFINITION, which the enforcement path already declares and whose rows - already carry the values — so there is nothing for this to add, and the permission editor - cannot offer a `ut_*` shared column in the first place. Wiring that stratum in is a change - to what an admin may WALL ON, and it belongs with the picker change that offers it. - """ - if st is None: - return {} - try: - import aios_grid - import core.shared_overlay as shared_overlay - import core.view_templates as view_templates - - bucket = view_templates.workspace_key(str(module or '')) - if not bucket: - return {} - # ⭐ `defs` IS THE CALLER'S SNAPSHOT, AND PASSING IT IS NOT AN OPTIMISATION. - # `_enrich_for_wall` derives the VALUES from one read of this bucket and the - # answerable SET from this function; taking a second read here would let the - # two disagree, and a set that names a key the values cannot serve is exactly - # how a blank gets merged and `is not` flips from deny-all to admit-all. See - # `shared_overlay.snapshot`. - doc = ({'fields': dict(defs)} if defs is not None - else st.get(shared_overlay.bucket(bucket))) - saved = doc.get('fields') if isinstance(doc, dict) else None - if not isinstance(saved, dict) or not saved: - return {} - out = {} - for f in (aios_grid.fields_from_workspace({'fields': saved}, fields_base=[]) or ()): - if not isinstance(f, dict) or not f.get('key'): - continue - if f.get('source') != 'overlay' or f.get('derived'): - continue - out[str(f['key'])] = f - return out - except Exception: # noqa: BLE001 - return {} - - -def prune_filter_to_fields(tree, valid_keys): - """`(tree, dropped)` — `tree` with every leaf naming a column outside `valid_keys` removed. - - ⭐⭐ THE CASCADE OWNER I16 ASKS FOR: *"If the field is deleted, its permission filter goes - with it."* A stored permanent filter naming a column the database no longer has is not - ignored by the wall — `apply_row_scope` uses `permits`, which DENIES anything it cannot - answer — so a deleted column silently converts that account's grid to zero rows. Dropping - the leaf is what the owner accepted instead. - - ⛔⛔ THIS IS AN ADMIN-DOOR OPERATION AND IT MUST NEVER MOVE INTO THE ENFORCEMENT PATH. - `verify_perm_scope`'s row-scope leg asserts, in these words, *"a wall naming a DELETED column - denies every row (never ignored)"* — it puts `{'colId': 'ghost', ...}` in a stored filter and - calls `apply_row_scope` directly. If the wall started ignoring unknown leaves, that gate would - flip in the FAIL-OPEN direction and a real permission wall would quietly stop walling. So the - prune runs where a RECORD is read or written, and the wall keeps denying as the backstop for - anything the prune has not reached yet. - - ⛔ PER LEAF, WHERE `routes_admin._prune_to_module` IS ALL-OR-NOTHING PER GROUP, AND THE - DIFFERENCE IS DELIBERATE. That function folds a LEGACY wall into a module that may not be - able to evaluate it, where half a group is a rule nobody wrote. This one answers a different - question: one named column is GONE, and the owner's instruction is that its leaf goes with it - while the rest of the admin's rule stands. `done-when` says so in as many words -- *"leaving - the user's other filters intact"*. - - ⚠ AND THE WIDENING IS REAL, SO IT IS STATED RATHER THAN BURIED. Dropping a leaf from an `and` - group WIDENS that wall, and dropping the last leaf anywhere removes it altogether. That is - the direction the owner chose over deny-every-row; it is also why `valid_keys` must be a - RESOLVED vocabulary (see `user_generated_fields`) and never a degraded one. - - ⚠ A LEAF'S `rhs` COUNTS AS NAMING A COLUMN. A column-to-column comparison whose right side - was deleted is just as stale as one whose left side was, and `clean_filter_tree` would answer - it by dropping the `rhs` alone -- turning "revenue > forecast" into a comparison against a - literal, which is a DIFFERENT question wearing the original's shape. - - ⛔ AN EMPTIED TREE IS `None`, NEVER `{'conj': 'and', 'nodes': []}`. Measured: `permits` - returns True on an empty node list, so an empty-but-present tree admits every row -- while - `wall_declared` and `row_scope_applies` both read it as TRUTHY and answer that a wall applies. - A door would then build rows to filter them against nothing, and an editor would paint a rule - that is not there. `None` is the one shape all three agree about. - """ - valid = {str(k) for k in (valid_keys or ())} - if not valid: - # ⛔ A FLOOR, NOT AN OPTIMISATION. An empty vocabulary would prune EVERY leaf, which for a - # module whose schema momentarily failed to resolve is the whole wall gone in one read. - return tree, [] - dropped = [] - - def _keep(node): - if not isinstance(node, dict): - return None - kids = node.get('children') - if isinstance(kids, list): - surviving = [k for k in (_keep(k) for k in kids) if k is not None] - # An emptied GROUP carries no meaning once persisted -- the same rule - # `clean_filter_tree` applies to one it built. - return dict(node, children=surviving) if surviving else None - named = [node.get('colId')] - rhs = node.get('rhs') - if isinstance(rhs, dict) and rhs.get('kind') != 'stat' and rhs.get('colId') is not None: - named.append(rhs.get('colId')) - stale = [str(c) for c in named if c is not None and str(c) not in valid] - if stale: - dropped.extend(stale) - return None - return node - - if not isinstance(tree, dict): - return tree, [] - nodes = tree.get('nodes') - if not isinstance(nodes, list): - return tree, [] - kept = [n for n in (_keep(n) for n in nodes) if n is not None] - if not dropped: - return tree, [] # unchanged by construction, not merely equal - if not kept: - return None, sorted(set(dropped)) - return {'conj': 'or' if tree.get('conj') == 'or' else 'and', 'nodes': kept}, \ - sorted(set(dropped)) - - -# ── 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 ` 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 - - -# ── C1: THE ONE DOOR TO ANY DATABASE'S ROWS (wave 36, W36-T20) ──────────────────────────────── -#: ⭐⭐ OWNER RULING R6, AND IT IS WHY THIS SECTION EXISTS AT ALL: *"EVERY database gets the same -#: permission logic, always"* — per-user field visibility AND row filtration on every database -#: carrying a unique id, whatever created it, with a NEW database inheriting it by construction -#: rather than by a list somebody maintains. -#: -#: ⛔ THE PRODUCT HAD TWO PERMISSION SYSTEMS AND ONLY ONE WAS ARMED. Everything above this line -#: walls the REGISTRY topics (`customer_data`, `product_data`) and is called only from the topic -#: assemblies. Every OTHER database is a `ut_*` table walled by `user_tables.may_open` alone — -#: creator, admin, or a `core.shares` grant — which is a BINARY door: you see all 31,418 rows of -#: `ut_odoo_invoices` or none of them. `perms.tenant_governable_modules`' docstring booked this -#: work in as many words (*"Arming `perm_scope` over `ut_*` … booked, not faked"*), and owner -#: item 11 is that booking coming due. -#: -#: ⚠ AND THE PREMISE THE GRILL GOT WRONG, because the fix depends on it: those databases are NOT -#: user-created. Ten of them (`ut_odoo_invoices`, `…_orders`, `…_agents`, `…_accounts`, `…_bills`, -#: `…_vendors`, `…_order_lines`, `…_gl_lines`, `…_customers`, `…_products`) are generated by the -#: KEYCHAIN connector (`aios-web/api/odoo_relational.py`). **`ut_` is a storage prefix, not a -#: statement about origin**, and a wall keyed off it was reading a naming artefact as a security -#: boundary. -#: -#: ⛔⛔ THE TWO QUESTIONS STAY TWO QUESTIONS. `may_open` answers *"IF you see this database"* and -#: is untouched by this section; C1 answers *"WHICH rows and fields"*. `may_read` below COMPOSES -#: them — it calls `may_open`, it does not reimplement it — because merging them is how this -#: codebase got two ideas of who owns a table once already (`user_tables.may_open`'s own wave-20 -#: note). One resolver per question, asked in order. - - -class UnknownTable(LookupError): - """No database in this tenant answers to that key. - - ⛔ RAISED, NEVER RETURNED AS AN EMPTY LIST (contract C1). An empty list reads as *"this - database is empty"* — indistinguishable from a real empty table, and the caller least able to - notice is the one that wanted rows. This repo has shipped that exact silent-empty answer - before (`user_tables.all_defs`' own correction note; [[empty-answer-vs-unfinished-answer]]). - """ - - -class Denied(PermissionError): - """This principal may not read this database at all. The IF question, answered by `may_read`.""" - - -class Unresolvable(RuntimeError): - """The rows exist and cannot be served under this call's constraints — R6's SECOND SENTENCE. - - ⭐ STANDING RULE 1 IS TWO SENTENCES AND THE SECOND IS THE HALF THAT GETS DROPPED: *"if there - is lag or it can't be done, you need to explicitly tell me why and recommend a fix"*. So a - limit that genuinely cannot be removed is REPORTED with its cause and a recommendation, never - silently enforced as a short answer. Carries the same four keys - `routes_tables._PID_SCOPE_LIMIT` already puts on the wire, so a route can hand this straight - to a client without a second vocabulary ([[one-question-two-normalizers]]). - """ - - def __init__(self, subject, effect, cause, recommendation): - self.subject, self.effect = subject, effect - self.cause, self.recommendation = cause, recommendation - super().__init__(f"{subject}: {effect}. {cause}. {recommendation}") - - def as_limit(self): - """The dict shape `routes_tables` puts in an assembly's `limits` list.""" - return {"subject": self.subject, "effect": self.effect, - "cause": self.cause, "recommendation": self.recommendation} - - -#: Row readers DECLARED by the app layer, keyed by EXACT database key. -#: `reader(table_key, user, st) -> (fields, rows)`. -#: -#: ⛔ WHY A REGISTRY AND NOT AN IMPORT. `core` never imports up (`platform/ARCHITECTURE.md`), and -#: a registry TOPIC's rows are built by `modules/` + `aios_grid` behind an API-layer pool cache -#: (`routes_customers._pool_for`), which is two layers above this file. Same idiom `user_tables` -#: already uses for exactly this reason — `register_connected`, `register_read_through`, -#: `ROW_HOOKS`: *"`core` never imports up, so the app tells this layer rather than being -#: interrogated by it."* -_ROW_SOURCES = {} - -#: THE reader for a read-through `ut_*` grid — one reader, because there is one mirror. -#: `reader(table_key, field_keys, st) -> rows`. -_MIRROR_READER = None - - -def register_rows(reader, *table_keys): - """Declare who reads a NAMED database's rows. Returns the registered key set. - - ⚠ The return value is the registrar's own answer on purpose: a public function whose only - caller is a `verify_*.py` file is a feature no user can reach, and this repo has a gate that - says so ([[reachable-is-not-the-same-as-built]]). Routing the read door through the write - door's return keeps one construction site of the set instead of two. - """ - for key in table_keys: - k = str(key or '').strip() - if k: - _ROW_SOURCES[k] = reader - return frozenset(_ROW_SOURCES) - - -def register_mirror(reader): - """Declare THE reader for read-through `ut_*` grids (`routes_tables._read_through_rows`).""" - global _MIRROR_READER - _MIRROR_READER = reader - return _MIRROR_READER is not None - - -#: ⛔ `row_sources()` IS DELETED (W36-T24 / owner item 13), AND THE REASON IS THE ONE THIS WAVE -#: KEEPS FINDING. It returned `frozenset(_ROW_SOURCES)` under a docstring calling itself *"The ONE -#: list to read"* — and `register_rows` ALREADY returns exactly that, which is the same idiom -#: `user_tables.register_connected_prefix` uses and the same reason: routing the read door through -#: the write door's return keeps ONE construction site of the set. A second accessor beside it is a -#: parallel path with nothing of its own to say, and it shipped with no caller outside `verify_*.py` -#: — the shape that is whole, correct and unreachable ([[artifact-with-no-importer]]; reported by -#: the integrator's `web_reachability` pass, `mailbox/A.md` A-43). The registrar's return is the -#: read: `routes_grid._C1_ROW_SOURCES` is that value, held where it is registered. - - -def _ut(): - import core.user_tables as user_tables - return user_tables - - -def may_read(user, table_key, st=None): - """May this principal read `table_key` AT ALL — the IF question, on EVERY database. - - ⛔ COMPOSED, NOT RE-DERIVED, and the order is the whole rule: - - 1. an admin reads everything (break-glass — `deps._user_for` hands back a hardcoded master - dict on a store outage and it will never carry a `perms` block); - 2. an EXPLICIT stored `access: false` DENIES, on any database. This is the toggle owner - item 11 asks for, and it is a **deny-only overlay**: it may revoke a database the wall - below would admit, and it may never grant one that wall refuses; - 3. a `ut_*` database defers to `user_tables.may_open` — creator, admin, or a `core.shares` - grant — UNMODIFIED. W36-T21: *"`may_open` still decides IF the database is visible."* - 4. anything else is a registry topic and defers to `may_access` above. - - ⛔⛔ WHY ABSENCE MUST NOT DENY ON A `ut_*` KEY, which is the opposite of what leg 4 does. - `may_access` reads migrated-and-undeclared as DENY — correct for a topic, because - `routes_admin` writes an entry for every governable topic on every save. ⚠ NO `ut_*` ENTRY WAS - STORABLE AT ALL UNTIL W36-T22 — `_clean_perms` refused the key with `unenforced_module` — so - every record migrated before this wave carries no entry for any of them, and reading that - absence as a decision would revoke all ten keychain databases from every migrated account the - moment this arms. That is not R6, it is an outage. Leg 3 therefore asks the wall that HAS been - answering rather than the marker that has not, and it keeps being right AFTER the flag's - deletion: an admin who has never opened the editor for a database has still not decided - anything about it. - - ⛔⛔ PASS THE **PUBLIC** RECORD, NOT THE ONE OUT OF `users.json`. Leg 3 needs a username, and a - stored record is keyed BY username in that bucket and does not carry one INSIDE it — only - `core.users._public(uname, rec)` puts it there, which is what `deps.Session.user` holds. Hand - this the raw record and `may_open` gets a `None` viewer and fail-closes, so every `ut_*` - database reads as DENIED for an account that can open all of them. It fails in the SAFE - direction and is silently wrong, which is the worst pair to debug — it cost two call sites in - one afternoon: a gate double, and `routes_admin.get_perms`' own fix for this very outage. - """ - if perms.is_admin(user): - return True - e = entry(user, table_key) - if e is not None and not bool(e.get('access', True)): - return False - key = str(table_key or '') - if key.startswith(_ut().KEY_PREFIX): - return bool(_ut().may_open(key, (user or {}).get('username'), False, st=st)) - return may_access(user, table_key) - - -def wall_declared(user, table_key): - """Is a ROW or FIELD narrowing declared for this principal on this database? - - ⛔ THE QUESTION A DOOR ASKS BEFORE SERVING ROWS IT CANNOT SCOPE. `perms.py` warned that a - stored `ut_*` wall would be INERT — *"the editor would say DENY, the table routes would keep - serving, and nothing anywhere would say so"*. A route that cannot apply C1 must therefore - REFUSE for a principal this returns True for, rather than serve the whole database. False for - an admin (they bypass the wall entirely) and for any record with no entry, so a door asking - this pays nothing and changes nothing for everybody who has no wall. - """ - if perms.is_admin(user): - return False - e = entry(user, table_key) - if not e: - return False - return bool(e.get('filter')) or bool(e.get('hiddenFields')) - - -def row_scope_applies(user, table_key): - """Does a permanent ROW filter narrow this principal on this database? - - ⚠ `wall_declared`'s narrower half, and it exists so a rows-free caller can SKIP building rows - it would only need in order to filter them. `routes_tables.scoped_pids` is that caller: its - whole point is that the pid set costs no row pass, and paying for one on every database - switch — for every account, walled or not — would undo W30-T30 to enforce a rule that applies - to almost nobody. Asked here rather than spelled out at the call site, so there is ONE - statement of when the row wall bites ([[one-question-two-normalizers]]). - """ - if perms.is_admin(user): - return False - return bool((entry(user, table_key) or {}).get('filter')) - - -def scoped_table(user, table_key, st=None, ctx=None): - """⭐⭐ CONTRACT C1 — the rows of ANY database, already field-stripped and row-filtered for - `user`. Registry topic or `ut_*`; there is no third kind and no per-database branch. - - rows = scoped_table(user, 'ut_odoo_invoices') # a keychain database - rows = scoped_table(user, 'customer_data') # a registry topic - - `user` is a user RECORD (the dict `deps.Session.user` carries), not a username — the whole - wall is a pure function of that record. BOTH arguments are positional and REQUIRED: a caller - that forgets the principal must not run, because the only thing a defaulted one could mean is - "unscoped", which is the widening direction. - - ⛔ FAIL-CLOSED, THREE WAYS, AND EACH IS A DIFFERENT EXCEPTION so a caller can answer with the - right status instead of guessing: `UnknownTable` (no such database — never an empty list), - `Denied` (the IF question said no), `Unresolvable` (the rows cannot be served and here is - why — standing rule 1's second sentence). - - ⚠ NO CAP. A connected source is read THROUGH the mirror in full (standing rule 1); the only - thing that stops it is a population that exceeds one materialisation window, and that arrives - as `Unresolvable` carrying its cause and a recommendation rather than as a short answer. - - ⭐ E's SCRIPT SANDBOX HOLDS NO SECOND PATH TO THE STORE (wiring W1), which is why this is - THE door rather than A door: everything a sandboxed script may read, it reads here, under the - CALLING user's scope (R5). - """ - _fields, rows = _scoped(user, table_key, st=st, ctx=ctx) - return rows - - -def scoped_fields(user, table_key, st=None): - """The COLUMNS of any database this principal may see — C1's other half. - - ⛔ IT IS NOT A CONVENIENCE, IT IS THE SECOND WIRE. `strip_row`'s own note above says it: the - field list and the row payload are two different wires, and narrowing one without the other - leaves the value sitting where anything can read it. A caller that must render a scoped table - needs both, and E cannot read a `ut_*` definition to learn its columns — the sandbox has no - second path to the store (W1). So both come from here, off one wall. - - ⚠ On a `ut_*` database this reads the DEFINITION only — the projection, no rows (D-213). On a - registry topic it goes through the registered reader, which builds that topic's pool; the - pool is cached per scope on the tenant runtime, so it is a cache hit next to `scoped_table`. - """ - fields, _rows = _scoped(user, table_key, st=st, ctx=None, want_rows=False) - return fields - - -def _scoped(user, table_key, st=None, ctx=None, want_rows=True): - """`(fields, rows)` — ONE evaluator behind both public doors, so they cannot disagree.""" - key = str(table_key or '').strip() - if not key: - raise UnknownTable('a database key is required. This door will not guess which database ' - 'was meant') - if not may_read(user, key, st=st): - raise Denied(f"this account may not read '{key}'") - fields, rows = _read(key, user, st, want_rows) - # THE FIELD WALL — a TRANSITIVE closure, so hiding a column also hides every formula computed - # FROM it. Resolved ONCE and used for both wires; see `hidden_keys` for why a set difference - # is the wrong shape here. - # ⭐ W38-T16 — `st` RIDES INTO THE WALL, not just into the read. The field-grant leg resolves - # against `object_shares` in THIS tenant's store; without the handle it would answer from the - # module default (tenant #0) and hide a grantee's own column on every other tenant. - hide = hidden_keys(user, key, fields, st=st) - if not want_rows: - return (visible_fields(fields, user, key, st=st) if hide else fields), [] - # THE ROW WALL — `permits()`, so a permanent filter this evaluator cannot answer DENIES - # rather than being ignored. Evaluated against the UNSTRIPPED contract on purpose: a - # permanent filter may name a column the reader is not allowed to SEE, and dropping the - # predicate would widen the read rather than narrow it. - # ⭐ OWNER I16 — `st` RIDES INTO THE ROW WALL TOO, and this door is the one that already had - # the handle and simply did not pass it down. The field wall two lines up has taken it since - # W38-T16 for the same reason: a wall resolved without a tenant handle answers from the - # module default, and here that means a user-generated column reads as unanswerable and - # denies every row. - rows = apply_row_scope(rows, user, key, fields, ctx, st=st) - if hide: - fields = visible_fields(fields, user, key, st=st) - rows = [strip_row(r, hide) for r in rows] - return fields, rows - - -def _read(table_key, user, st, want_rows=True): - """`(fields, rows)` BEFORE the wall — the app layer's reader, or core's own for a `ut_*`.""" - reader = _ROW_SOURCES.get(table_key) - if reader is not None: - fields, rows = reader(table_key, user, st) - return list(fields or ()), list(rows or ()) - ut = _ut() - if not table_key.startswith(ut.KEY_PREFIX): - # ⛔ A TOPIC WITH NO REGISTERED READER IS UNKNOWN, NOT EMPTY. In a process that never - # imported the API layer this is the honest answer: nothing here can build that pool. - raise UnknownTable(f"no database named '{table_key}' in this workspace, and no reader " - f"is registered for it") - return _read_user_table(table_key, st, want_rows) - - -def _read_user_table(table_key, st, want_rows=True): - """core's OWN reader for a `ut_*` database. Answers with NO registrar, deliberately. - - ⭐ WHY IT LIVES IN `core` RATHER THAN BEING REGISTERED LIKE THE TOPICS, and it is the same - argument that seeds `user_tables._CONNECTED_PREFIXES` rather than registering it: a cold - process — E's sandbox subprocess, a worker, a gate — that never imported an API route still - owes the right answer for `ut_odoo_invoices`. A registrar-only design would raise there, and - the sandbox is exactly such a process. - - ⚠ THE WALL IS ANSWERED ON A PROJECTION AND THE ROWS ARE NOT. `lend_defs` serves definitions - without the 28.6 MB of rows (D-213), which is every read this function makes when - `want_rows` is false; a projected document RAISES on `rows` rather than answering empty, so - the materialised arm below takes the whole read explicitly. - """ - ut = _ut() - lent = ut.lend_defs(st) - defn = ut.get(table_key, st=lent) - if not defn: - raise UnknownTable(f"no database named '{table_key}'") - fields = [dict(f) for f in (defn.get('fields') or [])] - if not want_rows: - return fields, [] - if not ut.materialises(table_key, st=st, defn=defn): - # A read-through grid stores no rows here — they live in the mirror, and reading - # `defn['rows']` would find an empty dict and serve an EMPTY GRID: correct-looking, - # wrong, and silent. - if _MIRROR_READER is None: - raise Unresolvable( - subject='rows', effect='unreadable', - cause=(f"'{table_key}' is served read-through from the connector mirror and no " - f'mirror reader is registered in this process'), - recommendation=('call `perm_scope.register_mirror(...)` from the app layer before ' - 'reading a read-through database, or read it through the API')) - keys = {f['key'] for f in fields if f.get('key')} - return fields, list(_MIRROR_READER(table_key, keys, st) or ()) - whole = ut.get(table_key, st=st) - if whole is None: - # Deleted between the wall and here. The same refusal, not an empty table. - raise UnknownTable(f"no database named '{table_key}'") - field_keys = {f['key'] for f in fields if f.get('key')} - rows = [] - for rid, row in (whole.get('rows') or {}).items(): - if not str(rid).isdigit(): - continue - r = {k: v for k, v in (row or {}).items() if k in field_keys} - r['pid'] = int(rid) - rows.append(r) - rows.sort(key=lambda r: r['pid']) - return fields, rows +"""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'] = {'': {'access': bool, + 'filter': {'conj'?: 'and'|'or', 'nodes': [...]} | None, + 'hiddenFields': ['', ...]}} + 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: []` 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 + + +def may_metrics(user, module): + """May this account build and receive METRIC columns — lookback measures — on `module`? + + ⭐⭐ W38-T19 — A CAPABILITY, NOT A SECOND SPELLING OF ACCESS, and the distinction is the + ticket. A rollup aggregates the CHILDREN a row is linked to; a Metric answers *"this number, + over this window"* against a governed topic with no relation at all (CLAUDE.md standing rule + 9). So it reads the book behind the rows rather than the rows: an account can be exactly the + right person to see a customer list and the wrong one to mint 12-month revenue over it. Two + decisions, two controls. + + ⛔ ABSENCE GRANTS, AND THE ASYMMETRY WITH `may_access` IS DELIBERATE RATHER THAN AN + OVERSIGHT TO TIDY. `may_access` reads migrated-and-undeclared as DENY, which is right there + because every save writes an entry for every governed database — absence means an + administrator decided. No `metrics` KEY was STORABLE before this ticket, so every migrated + record in every tenant carries none, and reading that absence as DENY would revoke Metrics + for everybody on the day this shipped with nobody having decided anything. That is the exact + failure `routes_admin.get_perms` records twice already (the `ut_*` default and the surface + default), one field further down the same entry. **Only an explicit `metrics: false` + refuses.** + + ⚠ IT DOES NOT RE-ASK ACCESS. Every caller is already behind the access wall (`module_gate`, + `may_read`, `session.require`), and folding admission in here would give a denial two + possible causes with one answer — the shape that makes a permission bug take an afternoon. + """ + if perms.is_admin(user): + return True + e = entry(user, module) + return not (isinstance(e, dict) and e.get('metrics') is False) + + +def nav_may_open(user, key, st=None): + """May this account SEE `key` — in the nav, and at the route gate? The ADMISSION question. + + ⛔⛔ WHY THIS EXISTS: THE EDITOR'S DECISION REACHED THE READ DOOR AND NOTHING ELSE. Measured + on live 2026-08-18, on a real account. An administrator ticked *Odoo products* for Naomi in + Manage user and saved; the record stored `perms.product_data.access = True` and + `may_access()` agreed. **The database never appeared.** `perms.nav_pages` and + `deps.Session.require` both ask `perms.may_open` — the LEGACY `user['modules']` array — which + still read `['sales', 'customers', 'products']`, and `products` is the key of the ARCHIVED + *SKU* module, not of `product_data`. So `nav_pages` answered `['customer_data']` and the + route would have 403'd her even by URL. Two permission systems, the editor writing one and + every DOOR reading the other ([[two-permission-systems-one-armed]]). + + ⭐ THE ASYMMETRY IS THE WHOLE DESIGN, and it is not the same rule twice: + + 1. an admin sees everything (break-glass, as everywhere else); + 2. an EXPLICIT `access: false` DENIES, on any key — an administrator unticking a box must + take the row off the nav, and before this it did not; + 3. a `ut_*` key with no explicit deny defers to `user_tables.may_open` — creator, admin or + a `core.shares` grant — and **an `access: true` entry may NEVER widen past it**. The + editor must not become a way to hand somebody another user's private table; + 4. a REGISTRY TOPIC with an explicit entry takes that entry, grant included. Here the + editor IS the authority: `_clean_perms` writes an entry for every governed topic on + every save, so an entry means an administrator decided; + 5. anything else — a module this editor does not govern — falls through to + `perms.may_open`, UNCHANGED. + + ⛔ LEG 5 IS LOAD-BEARING AND IT IS WHY `may_access` COULD NOT SIMPLY BE CALLED HERE. + `may_access` reads migrated-and-undeclared as DENY, which is correct for a governed topic and + catastrophic for the nav: Naomi's block declares the ten governed keys and nothing else, so + `sales` would have gone from visible to denied — an outage dressed as a permission fix. + """ + if perms.is_admin(user): + return True + e = entry(user, key) + if e is not None and not bool(e.get('access', True)): + return False + if str(key or '').startswith(_ut().KEY_PREFIX): + return bool(_ut().may_open(key, (user or {}).get('username'), False, st=st)) + if e is not None: + return bool(e.get('access', True)) + return perms.may_open(user, key) + + +def assistant_entry(user, module): + """The explicit database grant an Assistant snapshot may rely on, else ``None``. + + The interactive application retains an admin break-glass path and a temporary legacy-grant + compatibility path. Neither is an answer at the Assistant's app-stored data boundary: + that reader must be able to name the migrated grant that admitted a database. In particular, + a store-outage admin identity with no ``perms`` document is not an unresolved permission that + may be widened into data access. + """ + e = entry(user, module) + if (not is_migrated(user) or not isinstance(e, dict) + or not bool(e.get('access', True)) or not may_access(user, module)): + return None + return e + + +# ── FIELDS ─────────────────────────────────────────────────────────────────────────────────── +#: ⭐⭐ W38-T16 — THE MARKER THAT SAYS "THIS COLUMN IS GOVERNED BY A GRANT", stamped once by the +#: door that creates a shared column (`routes_tables.patch_shared_cell`) and never flipped by any +#: door afterwards. It is the `perms_v` idiom one object down, and it is here for the reason that +#: marker exists: **absence must not be read as a decision.** +#: +#: ⛔⛔ WHY A MARKER AND NOT "DOES A GRANT RECORD EXIST". Every tenant-wide column shipped before +#: this ticket carries no `field` grant record, because none was STORABLE — `shares.KINDS` had +#: three members. Reading that absence as "granted to nobody" would blank every existing shared +#: column for every account the moment this arms, which is not a permission fix, it is an outage +#: (`may_read` leg 3 carries the same argument for `ut_*` keys, in the same words). +#: +#: ⛔⛔ AND IT IS WHAT MAKES THE WALL FAIL **CLOSED**. The polarity here is the opposite of every +#: other grant check in this repo: a field share is a GRANT, so "no grant" has to mean HIDDEN or +#: the wall does nothing — but "no grant record" also describes a legacy column and a store that +#: could not be read. The marker separates the three: `granted` on the column means an explicit +#: decision was taken, so an unreadable registry hides it; no marker means legacy, so nothing +#: changes. Without it, one unreadable read of `object_shares` would publish every governed +#: column to the whole tenant, silently ([[a-guard-for-the-dangerous-case]]). +#: +#: ⚠ IT IS NOT `shared_overlay`'s `"shared": True` AND MUST NOT BE CONFUSED WITH IT. That flag is +#: written and never read (D-414) — `is_shared` is a dict-membership test — so it is not evidence +#: of anything. This one is read HERE, on every assembly, and the only writer is the create door. +FIELD_GRANT_MARK = 'granted' + + +def granted_field_keys(fields): + """Every column in `fields` that declares itself GOVERNED by a `shares` grant. + + ⭐ THE CHEAP HALF OF THE WALL, AND IT IS WHY THE WALL COSTS NOTHING FOR ALMOST EVERYBODY. It + is a scan of dicts already in memory, so a database with no governed column reaches no store + at all and `hidden_keys` behaves exactly as it did before this ticket. The registry is only + opened once this answers non-empty. + """ + return {str(f['key']) for f in (fields or ()) + if isinstance(f, dict) and f.get('key') + and f.get(FIELD_GRANT_MARK) is True} + + +def field_grant_hidden(user, table_key, fields, st=None): + """The governed columns of `table_key` this principal holds NO grant on — C1's per-FIELD wall. + + ⭐⭐ W38-T16 / R7 / R8 — THE THIRD WALL, AND IT COMPOSES ALONGSIDE THE OTHER TWO RATHER THAN + INSIDE THEM. `may_open` answers *IF* you reach a database; `may_read`'s stored `access: false` + overlay may REVOKE one; this answers *WHICH COLUMNS* of it you receive. It is deliberately not + threaded through that overlay: the overlay is **deny-only** by its own docstring (*"it may + revoke a database the wall below would admit, and it may never grant one that wall refuses"*) + and a field share is a GRANT — the widening direction. Merging them would give the codebase a + second idea of who grants what, which is the failure `may_open`'s own note is the record of. + It composes the way `may_open`'s grant leg does: additively, last, fail-closed. + + ⛔ AN ADMIN IS NOT WALLED (break-glass, as everywhere else) and neither is the column's OWNER — + `shares.role_for` answers `'owner'` for the creator, so a person cannot lose their own column + by forgetting to share it with themselves. + + ⚠ `st` IS THE TENANT HANDLE AND ITS ABSENCE IS SAFE HERE, unlike everywhere else. A caller + that cannot lend one reads the module-default bucket; on any tenant but #0 that finds no + grant, and no grant on a MARKED column means HIDDEN. So a door that has not learned to thread + `st` under-shares rather than over-shares, and the symptom is a grantee who cannot see their + column — visible, reportable, and the opposite of a leak. + """ + if perms.is_admin(user): + return set() + marked = granted_field_keys(fields) + if not marked: + return set() + uname = str((user or {}).get('username') or '').strip().lower() + try: + import core.shares as shares + except Exception: # noqa: BLE001 + return set(marked) + if not uname: + # No principal, and a marked column is an explicit decision: nobody is not somebody. + return set(marked) + # ⭐ THE CREATOR IS READ OFF THE COLUMN, NOT OUT OF THE REGISTRY, AND THAT IS NOT A SECOND + # AUTHORITY. `createdBy` is ALREADY what decides who may DELETE a shared column (R8 / D-172, + # `routes_tables.delete_shared_field`) and who may CLAIM it (`routes_shares._owns_object`); + # asking the same field here keeps one answer to "whose column is this" across all three. + # ⛔ IT IS ALSO THE ONLY THING THAT SURVIVES A CLAIM THAT NEVER LANDED. The create door writes + # the definition first and the grant record second, on purpose — so the window where a column + # is marked and unclaimed exists, and without this line its own author would be walled out of + # the column they just made, permanently and with no way to fix it but an admin. + mine = {str(f['key']) for f in (fields or ()) + if isinstance(f, dict) and f.get('key') and str(f['key']) in marked + and str(f.get('createdBy') or '').strip().lower() == uname} + hide = set() + for key in marked - mine: + try: + oid = shares.field_oid(table_key, key) + role = shares.role_for('field', oid, uname, is_admin=False, st=st) + except Exception: # noqa: BLE001 + role = None + if role is None: + hide.add(key) + return hide + + +def _measure_bound_keys(hidden, fields): + """Every column BOUND to a measure whose `measure_` pseudo-field is in `hidden`. + + ⭐ THE PREFIX IS IMPORTED, NEVER SPELLED. `aios_grid.MEASURE_FIELD_PREFIX` is the one + constant the client's `createField`, the host's `clean_measure_field` and now this wall all + key off; a literal "measure_" here would be the third copy, and the first to drift + [[constant-two-features-share]]. The import is lazy and function-local, which is the + established shape in this layer (`core/grid_events.py`, `core/user_tables.py` both do it) and + keeps `core` from pulling the grid module at import time. + + ⚠ CHEAP FIRST. Called only once `hidden` is already non-empty, and it returns on an empty + `wanted` before touching `fields` — so a wall with no metric tick costs one set + comprehension over a handful of strings, on a function that runs at every grid door. + + ⛔ A FAILED IMPORT HIDES NOTHING EXTRA RATHER THAN TAKING THE DOOR DOWN, matching + `field_grant_hidden`'s treatment of an unreachable `core.shares`. The direction is stated + because it is the unsafe one: this leg only ever WIDENS the hidden set, so losing it + under-hides — visible, reportable, and the symptom is a metric column that should have + been walled, not a database that will not open. + """ + try: + import aios_grid as _agm + prefix = _agm.MEASURE_FIELD_PREFIX + except Exception: # noqa: BLE001 + return set() + wanted = {h[len(prefix):] for h in hidden + if isinstance(h, str) and h.startswith(prefix) and len(h) > len(prefix)} + if not wanted: + return set() + out = set() + for f in fields or (): + if not isinstance(f, dict) or not f.get('key'): + continue + spec = f.get('measure') + if isinstance(spec, dict) and str(spec.get('key') or '') in wanted: + out.add(str(f['key'])) + return out + + +def hidden_keys(user, module, fields, st=None): + """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. + + ⭐⭐ W36-T21 — AND A ROLLUP IS THE SAME LEAK ONE MECHANISM OVER, which matters now that this + closure runs on the `ut_*` databases rather than only on the two registry topics. A rollup + names a LINK COLUMN OF THIS TABLE (`rollup.link`) and aggregates a field on the table that + link points at — so `ut_odoo_customers.ar_outstanding` is *"sum `residual` over the invoices + this row links to"*. Hide `invoices` and keep `ar_outstanding` and the reader still learns + what the hidden link contains, in aggregate; the three outcomes are exactly the three the + formula argument above enumerates, and only "strip both" is coherent. Verified against the + real declarations (`odoo_relational.customer_fields`) rather than assumed: every rollup there + is either `{'link': , 'field': }` + or a `source` topic aggregate, so `rollup.link` is the ONE same-table reference a rollup makes + and `rollup.field` is deliberately not treated as one — it names another database's column, + which has its own wall. + """ + if perms.is_admin(user): + return frozenset() + # ⭐⭐ W38-T16 — TWO SOURCES OF HIDING, ONE CLOSURE, AND THAT UNION IS THE WHOLE INTEGRATION. + # + # The administrator's `hiddenFields` and a field's own grant answer different questions and + # both end in the same place: a key this reader may not receive. Seeded together HERE, before + # the fixpoint, so the transitive argument above covers the new source unchanged — a formula + # (or a rollup) over a column this reader was not granted comes out with it, or the value + # leaks wearing the derived column's name. + # + # ⛔ AND THIS FUNCTION IS THE INSERTION POINT RATHER THAN `_scoped`, WHICH IS WHAT THE TICKET + # ASSUMED. `_scoped` is C1's evaluator and reaches C1's two doors; **the product reads through + # neither of them.** Every grid door calls THIS: `routes_customers:196`, `routes_products:345`, + # `routes_odoo_tables:920/1119`, `routes_tables._ut_hidden/_ut_field_wall`, `routes_slack:190`, + # `routes_grid:57/69/787` — and `grid_events` walls WRITES off the same set through + # `EventCtx.hidden_keys`. One evaluator, every door, both wires, read and write. + hidden = set(field_grant_hidden(user, module, fields, st=st)) + e = entry(user, module) + if e: + hidden |= {str(k) for k in (e.get('hiddenFields') or ()) if k} + if not hidden: + return frozenset() + # ⭐⭐ W40-T04 / I13 — A THIRD SOURCE, SEEDED BEFORE THE FIXPOINT FOR THE SAME REASON THE + # OTHER TWO ARE, AND IT IS WHAT MAKES THE NEW CHECKBOX ENFORCE ANYTHING. + # + # The permission editor now offers one `measure_`-namespaced PSEUDO-field per bound measure + # (`routes_admin._metric_fields`), so an administrator can tick "Metric - Gross margin $" and + # the wall stores `hiddenFields: ['measure_margin']`. But the COLUMNS that carry that number + # are keyed `measure__` by `CustomerGrid.createField` — `measure_margin_a1b2`, + # not `measure_margin` — and the seeding above compares KEYS. Without this line the box + # ticks, the record saves, every gate stays green and the reader keeps every gross-margin + # column on the grid. I13's own words are *"so a user can check the ones the permissioning is + # LIMITED TO"*; a control that limits nothing is the failure + # `verify_ui.py::metrics_toggle_has_a_mount_site` exists because of. + # + # ⭐ SO THE JOIN IS ON THE BINDING, NOT THE KEY: a hidden `measure_` hides every column + # whose `measure.key` IS ``, which is the same spec `aios_grid.clean_measure_field` + # wrote and `measure_fields_of` reads. One measure, however many windows a user minted over + # it, all walled by one tick. + # + # ⭐ ADDITIVE, NEVER SUBSTITUTIVE. The pseudo-key stays in `hidden` on its own account, so a + # real column that happens to be keyed exactly `measure_margin` is hidden exactly as before + # and nothing depends on the pseudo-field existing. + # + # ⛔ AND IT GOES HERE, ABOVE THE FIXPOINT, so the transitive argument this docstring already + # makes covers it unchanged: a FORMULA over a walled metric column, or a ROLLUP whose link is + # one, comes out with it. Seeded after the loop it would leak through the derived column, + # which is outcome two of the three the docstring enumerates. + hidden |= _measure_bound_keys(hidden, fields) + + 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()} + link = (f.get('rollup') or {}).get('link') if isinstance(f.get('rollup'), dict) else None + if isinstance(link, str) and link.strip(): + refs.setdefault(f['key'], set()).add(link.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, st=None): + """`fields` minus the hidden closure. Order preserved — the column order is the user's. + + ⚠ W38-T16 — `st` MUST MATCH WHAT ITS CALLER PASSED TO `hidden_keys`, and that is not a style + note. This recomputes the closure, and since the field-grant leg under-hides without a tenant + handle, a caller that lends one to `hidden_keys` and not to this would narrow the FIELD LIST + by MORE than it stripped from the ROWS — the value left sitting in the payload under a column + nobody can see, which is exactly the half-wall `strip_row`'s note exists to forbid. + """ + hide = hidden_keys(user, module, fields, st=st) + 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 assistant_visible_fields(fields, user, module): + """Visible closure under one explicit Assistant grant, including for an admin caller.""" + e = assistant_entry(user, module) + if e is None: + return [] + # `hidden_keys` deliberately preserves the interactive admin break-glass behaviour. The + # Assistant uses its explicit grant instead, but shares the same transitive formula closure. + hidden = {str(k) for k in (e.get('hiddenFields') or ()) if k} + if not hidden: + return list(fields or ()) + # ⛔⛔ THE SAME LEAK ONE DOOR OVER, AND IT IS THE SAME `hiddenFields`. `assistant_entry` + # returns the very dict `entry()` does, so once the permission editor can store + # `measure_margin` the Assistant's grant carries it too — and comparing KEYS would leave + # `measure_gross_profit_a1b2` in the snapshot this reader is handed. Seeded here for the + # identical reason and at the identical point as in `hidden_keys`: before the fixpoint, so a + # formula over a walled metric column comes out with it. + hidden |= _measure_bound_keys(hidden, fields) + + refs = {} + for field in fields or (): + if not isinstance(field, dict) or not field.get('key'): + continue + expr = field.get('formula') + if isinstance(expr, str) and expr: + refs[field['key']] = {match.strip() for match in _FORMULA_REF.findall(expr) + if match.strip()} + for _ in range(12): + 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 [field for field in (fields or ()) + if not (isinstance(field, dict) and field.get('key') in hidden)] + + +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} + + +def visible_overlays(overlays, user, module, fields, st=None): + """The overlay CELL MAP minus every column this reader may not receive — D-427. + + ⛔⛔ THE THIRD WIRE, AND IT CARRIES THE RAW VALUE. `strip_row`'s own docstring makes the + argument for two wires: *"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."* There is a THIRD. `/workspace?scope=product` serves + `workspace["overlays"] = g["ws"].get("overlays")` verbatim — the persisted user/tenant + stratum, keyed `{row id: {field key: value}}` — and that assignment never asks who is + reading. So an administrator hides `first_cost`, the grid dutifully drops the column and the + cell, and the same number is still sitting in the workspace payload under the same key. The + wall holds on two wires out of three, which is not a wall. + + ⭐ WHY THIS LIVES HERE RATHER THAN AT THE DOORS. `hidden_keys` is already the ONE evaluator + every grid door calls, and the three leaking assignments are one line each in three files. + Putting the narrowing beside `strip_row` means the fix at each door is a single call to the + module that already owns the question, instead of a fourth place that decides what a reader + may see. A second idea of who hides what is the failure `may_open`'s own note records. + + ⭐ SAME ARGUMENT ORDER AS `visible_fields`, deliberately: a door that already narrows its + field list has the four values to hand, and `st` MUST be the same handle it passed there. + The field-grant leg under-hides without a tenant handle, so a door that lends one to + `visible_fields` and not to this would strip the COLUMN while leaving the overlay VALUE — + the half-wall this function exists to close, re-created by the fix for it. + + ⚠ NON-DESTRUCTIVE. A new dict is built rather than the caller's mutated, because the same + overlay object is read again by `rows_from_pool` on the assembly path; and the input is + returned untouched when nothing is hidden, so a database with no wall pays one set test. + """ + hide = hidden_keys(user, module, fields, st=st) + if not hide or not isinstance(overlays, dict): + return overlays + return {rid: strip_row(cells, hide) if isinstance(cells, dict) else cells + for rid, cells in overlays.items()} + + +# ── ROWS ───────────────────────────────────────────────────────────────────────────────────── +def _wall_leaf_keys(nodes): + """Every `colId` a filter tree names, at any depth. Groups carry `children`; leaves do not. + + ⚠ THE SAME WALK AS `routes_admin._leaf_col_ids`, and the duplication is deliberate rather + than lazy: that one runs at the ADMIN DOOR inside the API package, this one runs in `core` + on the request path, and `core` never imports up (see `platform/ARCHITECTURE.md`). The two + are three lines each and `verify_api` asserts they answer the same set on the same tree, so + a grammar change cannot land in one and not the other. That equality is the point: the door + refuses through one walk and the wall resolves through the other, and a wall the editor + accepts but the enforcement path cannot see is the defect this whole change is about. + + ⛔ AN `rhs` COLUMN IS NOT COLLECTED, MATCHING `_leaf_col_ids` AND FAILING CLOSED. A + column-to-column leaf whose RIGHT side is a user-generated column stays unenriched, so + `is_rule_active` finds that side outside the contract, calls the rule inactive, and strict + mode turns that into a DENY. That is the safe answer, and it is the same one this door gave + before the enrichment existed. `prune_filter_to_fields` does walk `rhs`, deliberately, and + the asymmetry is the direction each function fails in: deleting a leaf WIDENS a wall, so + that one must see every column a leaf names; refusing to enrich one only narrows. + """ + found = set() + for node in nodes or (): + if not isinstance(node, dict): + continue + if isinstance(node.get('children'), list): + found |= _wall_leaf_keys(node['children']) + elif node.get('colId'): + found.add(str(node['colId'])) + return found + + +def _enrich_for_wall(tree, rows, fields, module, st): + """`(rows, fields)` — the same wall inputs, plus any USER-GENERATED column this wall names + that the SERVER can actually answer. Owner I16, the half `ut_*` got for free. + + ⭐⭐ THE DEFECT. On the two Odoo topic grids the wall runs as + `apply_row_scope(rows, user, MODULE, )` over PRE-OVERLAY rows, so a + column a user created is neither declared in that field list nor present on the row — and + `filter_eval.permits` DENIES every leaf it cannot answer. Measured four ways on the + integrated head, one leaf `{colId: , op: eq, value: West}`, two rows: + + static contract + pre-overlay rows (THE LIVE CALL SITE) -> [] every row denied + column declared + rows carrying the value -> ['1'] correct + column declared + pre-overlay rows -> [] + a DECLARED odoo column, same shape -> ['1'] the evaluator is fine + + So the administrator saves a rule, is told it saved, and that account opens an empty grid + with nothing on screen saying why. BOTH halves are needed and neither alone does anything, + which is why this function supplies both from one read. + + ⛔⛔ IT MAY ONLY EVER ADD THE ABILITY TO ANSWER — IT MUST NEVER ADMIT A ROW THE WALL WOULD + OTHERWISE REFUSE. That is why `wallable_overlay_keys` is defined as *the keys whose values + the very `cells` read below serves*, and not as "every overlay column". Merge a blank for a + key this store cannot really answer and `custom_x is not West` flips from denying every row + (undeclared leaf) to admitting every row (`'' != 'West'`), with `is empty` doing the same — + a silent widening wearing the shape of a fix. The set and the values come from ONE stratum + so they cannot disagree about what is answerable. + + ⛔ AND THE WHOLE THING IS WRAPPED FAIL-CLOSED. On ANY failure the caller's own `rows` and + `fields` come back untouched and `permits` denies exactly as it does today. A wall that + cannot be enriched is a wall that keeps refusing, never one that opens. + + ⚠ THE ORDER IS A COST DECISION, NOT A STYLE ONE. `missing` is answered from the tree and the + field list already in memory, and an empty `missing` returns BEFORE `wallable_overlay_keys` + is called — so a wall naming only declared columns (every wall that exists today) pays one + set difference and reaches no store at all. + """ + try: + from harness import filter_eval as fe + # ⛔ `tree_parts`, NEVER `tree['nodes']`. A `FilterTree` is a PAIR and a BARE LIST is also + # a legal shape (C-PERM amendment 2); a second reader of it here would answer "no leaves" + # for a wall the evaluator one line down reads perfectly well. + nodes, _conj = fe.tree_parts(tree) + leaves = _wall_leaf_keys(nodes) + if not leaves: + return rows, fields, frozenset() + declared = {str(f['key']) for f in (fields or ()) + if isinstance(f, dict) and f.get('key')} + missing = leaves - declared + if not missing: + return rows, fields, frozenset() # nothing to add — the common case, and it is free + resolvable = missing + if not resolvable: # unreachable as written: `missing` is non-empty three lines up. + # Kept as the SHAPE of the guard, because `resolvable` is narrowed AGAIN below once + # the snapshot says what is actually answerable, and that narrowing can empty it. + # A wall naming a column NOTHING here can answer still denies, which is the whole + # point of `permits`. Enrichment is not a licence to ignore an unanswerable leaf. + return rows, fields, frozenset() + import core.shared_overlay as so + import core.view_templates as vt + ws_key = vt.workspace_key(str(module or '')) + if not ws_key: + return rows, fields, frozenset() + source = list(rows or ()) + # ⚠ THE ROW'S CELL KEY IS DERIVED BY `shared_overlay`'s OWN RULE, `str(int(pid))`, and + # not by `str(pid)`. That is the one coercion the stratum has (pids are ints in the grid + # and strings in JSON), so a second spelling here would look up `'1.0'` in a document + # keyed `'1'` and merge a blank over a value that is right there. It also RAISES on a + # pid-less or non-numeric row: a `cells` call carrying one would fail the whole request + # CLOSED, i.e. disable the fix silently for every other row rather than going red, so + # such a row is simply given the blank instead. + keys = {} + for i, r in enumerate(source): + if not isinstance(r, dict): + continue + try: + keys[i] = str(int(r.get('pid'))) + except (TypeError, ValueError): + continue + # ⭐⭐ ONE READ FOR THE WHOLE PAGE, AND FOR BOTH HALVES OF THE QUESTION. `snapshot` + # returns the shared stratum's SCHEMA and its CELLS from a single `st.get`, and it + # refuses an "everything" read by signature, so the row set handed in IS the bound. + # + # ⛔⛔ THE TWO HALVES MUST COME FROM ONE SNAPSHOT, AND THIS USED TO BE TWO READS. A + # wave-40 adversarial probe drove the gap: with the second read returning an emptied + # `cells`, a blank is merged for a key the store cannot really serve, and + # `custom_x is not West` flips from denying every row to ADMITTING a row whose true + # value IS West. `is empty` does the same. Narrow under today's cache-first store and + # structurally real under a threaded server, so it is closed by construction rather + # than by being unlikely. + _defs, cells = so.snapshot(ws_key, list(keys.values()), st=st) + wallable = wallable_overlay_keys(module, st=st, defs=_defs) + resolvable = resolvable & set(wallable) + if not resolvable: + return rows, fields, frozenset() + merged, denied = [], [] + for i, r in enumerate(source): + if not isinstance(r, dict): + merged.append(r) + continue + if i not in keys: + # ⛔⛔ A ROW WHOSE `pid` DOES NOT RESOLVE GETS NOTHING MERGED, AND THAT IS THE + # WHOLE POINT. `keys` holds only indices whose pid survived `str(int(pid))`; for + # any other row the store was never asked, so its value is UNKNOWN -- a different + # thing from the legitimately blank cell a real pid with no stored value has. + # Merging `''` for it hands `is not X` and `is empty` a FABRICATION and treats it + # as ground truth: a wave-40 adversarial probe drove exactly that, admitting a + # no-pid row under `is not West`, and a non-numeric-pid row under `less than 5` + # on a numeric column -- both of which the un-enriched wall refuses. Leaving the + # key off keeps the leaf unanswerable, so `permits` denies. + # + # ⚠ Not reachable through either registered reader today: `customer_data` and + # `product_data` both emit integer Odoo ids. Fixed because the invariant this + # function states is UNCONDITIONAL, not because it happened to be reachable. + # ⛔ DENIED, not merely un-merged. Leaving the key off the row is NOT + # enough: `wall_fields` DECLARES the column, and `permits` reads a declared + # field whose key is absent from the row as BLANK -- the same fabrication one + # level down, and measured doing exactly that. The index is recorded and the + # door drops the row outright. + merged.append(r) + denied.append(i) + continue + row_cells = cells.get(keys[i]) or {} + # A COPY, never the caller's dict mutated. These rows are the pool the tenant + # runtime caches and hands to every other consumer on the request (the workspace, + # the cohorts, the measures); writing a wall's working value into them would put a + # column on a payload nobody asked for it on. + merged.append(dict(r, **{k: row_cells.get(k, '') for k in resolvable})) + return (merged, list(fields or ()) + [wallable[k] for k in resolvable], + frozenset(denied)) + except Exception: # noqa: BLE001 + return rows, fields, frozenset() + + +def apply_row_scope(rows, user, module, fields, ctx=None, st=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. + + ⭐⭐ `st` IS OWNER I16's SECOND HALF AND IT IS OPTIONAL SO THE FIRST HALF CANNOT MOVE. + *"Permission Filters must be able to filter on user-generated Fields too."* With a tenant + handle this door can resolve a user-generated column the static contract does not declare + (see `_enrich_for_wall`); WITHOUT one it is byte-identical to the wall that shipped before + this parameter existed, which is what keeps a cold process — a gate, a worker, E's sandbox — + behaving exactly as it always has. + + ⛔ PASS IT AT EVERY DOOR THAT WALLS A TOPIC GRID, OR AT NONE OF THEM. One stored wall read + through a door that lends the handle and a door that does not is one rule with two meanings, + which is worse than a uniform refusal: `allowed_pids` is the WRITE wall and `grid_assembly` + the READ one, and a user who may PATCH a row they cannot SEE is the hole + `allowed_pids`' own docstring exists to close. + """ + 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 + if st is not None: + # ⛔⛔ THE ENRICHMENT ANSWERS THE WALL AND MUST NEVER REACH A CALLER. It merges a + # column's value onto a COPY of each row so `permits` can evaluate a leaf naming it; + # returning those copies hands every consumer a column the caller's own field list + # does not declare. Measured in wave-40 QA against `core/script_sandbox.py`, which + # passes `scoped_table()`'s rows straight to a user-authored script: + # + # scoped_fields() declared keys: ['dba'] + # scoped_table() returned rows : [{'pid': 2, 'dba': 'Fisch', + # 'custom_region_qa': 'TOP-SECRET-VALUE'}] + # + # reachable by any ordinary session through `POST /script-views/{id}/run`. And the + # field-grant hide could never have caught it: `field_grant_hidden` can only mark a + # key already present in the `fields` it is handed, and this key never is. + # + # ⭐ So the decision is made on the enriched copy and the ORIGINAL row is what + # survives. `_enrich_for_wall` returns one entry per input row, in order, which is + # what makes the pairing sound; the copy is ONLY ever an argument to `permits`. + # ⚠ A length disagreement means the enrichment did not do what it promises, so the + # fall-through is the UN-enriched wall, which denies. Never the enriched rows. + # ⛔ MATERIALISED BEFORE THE ENRICHMENT SEES IT. `rows` may be any iterable, and + # `_enrich_for_wall` has three early-return paths that hand it straight back -- so a + # GENERATOR reached `len(judged)` and raised `TypeError: object of type 'generator' has + # no len()` on the common wall (one naming only declared columns), or, when enrichment + # did run, left the outer generator exhausted and the length check comparing against + # zero. Both fail closed, but one is a crash and the other a silent empty answer. One + # `list()` removes the class. Found by a wave-40 adversarial probe. + source = list(rows or ()) + judged, wall_fields, denied = _enrich_for_wall(tree, source, fields, module, st) + if len(judged) == len(source): + return [orig for i, (orig, seen) in enumerate(zip(source, judged)) + if i not in denied and fe.permits(tree, seen, wall_fields, ctx)] + return [r for r in (rows or ()) if fe.permits(tree, r, fields, ctx)] + + +def assistant_apply_row_scope(rows, user, module, fields, ctx=None, st=None): + """Apply an explicit Assistant grant's permanent filter without an admin bypass. + + `st` carries the same meaning it does on `apply_row_scope`, for the same reason: an Assistant + grant is stored by the same editor, against the same vocabulary, and a wall that means one + thing on the grid and another in the Analyst's answer is two walls. + """ + e = assistant_entry(user, module) + if e is None: + return [] + tree = e.get('filter') + if not tree: + return list(rows or ()) + from harness import filter_eval as fe + if st is not None: + # Same rule as `apply_row_scope`: judge on the copy, return the ORIGINAL. See the block + # there for the measured leak this prevents. + # ⛔ MATERIALISED BEFORE THE ENRICHMENT SEES IT. `rows` may be any iterable, and + # `_enrich_for_wall` has three early-return paths that hand it straight back -- so a + # GENERATOR reached `len(judged)` and raised `TypeError: object of type 'generator' has + # no len()` on the common wall (one naming only declared columns), or, when enrichment + # did run, left the outer generator exhausted and the length check comparing against + # zero. Both fail closed, but one is a crash and the other a silent empty answer. One + # `list()` removes the class. Found by a wave-40 adversarial probe. + source = list(rows or ()) + judged, wall_fields, denied = _enrich_for_wall(tree, source, fields, module, st) + if len(judged) == len(source): + return [orig for i, (orig, seen) in enumerate(zip(source, judged)) + if i not in denied and fe.permits(tree, seen, wall_fields, ctx)] + return [row for row in (rows or ()) if fe.permits(tree, row, fields, ctx)] + + +def validate_assistant_filter(tree, fields): + """Return a strict, detached Assistant filter tree or raise ``ValueError``. + + The display filter cleaner is intentionally permissive: it drops a stale column or leaves an + inactive condition alone so an old saved view can still open. An Assistant data reader cannot + inherit that behaviour — dropping a predicate turns a request for a subset into a wider read. + This validator therefore admits only visible field operands that the existing evaluator can + answer from one stored row. Cohort, measure and rank conditions need separate materialised + set resolvers and are refused here rather than guessed. + """ + if tree is None: + return None + from harness import filter_eval as fe + + by_key = {str(field.get('key')): field for field in (fields or ()) + if isinstance(field, dict) and field.get('key')} + if not by_key: + raise ValueError('assistant filter has no visible field contract') + + def _leaf(raw): + if not isinstance(raw, dict): + raise ValueError('assistant filter leaf must be an object') + allowed = {'id', 'colId', 'op', 'value', 'value2', 'rhs'} + if set(raw) - allowed: + raise ValueError('assistant filter carries an unsupported operand') + col = raw.get('colId') + op = raw.get('op') + if not isinstance(col, str) or col not in by_key: + raise ValueError('assistant filter names an unknown or hidden field') + if (not isinstance(op, str) or op not in fe.FILTER_OPS or op in fe.RANK_OPS + or op in {'between', 'within'} or col == fe.COHORT_FIELD): + raise ValueError('assistant filter uses an unsupported operator') + rhs = raw.get('rhs') + if rhs is not None: + if (not isinstance(rhs, dict) or rhs.get('kind') != 'field' + or set(rhs) - {'kind', 'colId'} + or not isinstance(rhs.get('colId'), str) + or rhs['colId'] not in by_key): + raise ValueError('assistant filter names an unknown or hidden right-hand field') + out = {name: raw[name] for name in ('id', 'colId', 'op', 'value', 'value2') + if name in raw} + if rhs is not None: + out['rhs'] = {'kind': rhs.get('kind'), 'colId': rhs['colId']} + if not fe.is_rule_active(out, by_key): + raise ValueError('assistant filter is inactive or unanswerable') + return out + + def _node(raw): + if not isinstance(raw, dict): + raise ValueError('assistant filter node must be an object') + if 'children' not in raw: + return _leaf(raw) + if set(raw) - {'conj', 'children'}: + raise ValueError('assistant filter group carries an unsupported operand') + children = raw.get('children') + if raw.get('conj') not in ('and', 'or') or not isinstance(children, list) or not children: + raise ValueError('assistant filter group must be a non-empty and/or group') + return {'conj': raw['conj'], 'children': [_node(child) for child in children]} + + if isinstance(tree, list): + if not tree: + raise ValueError('assistant filter list must not be empty') + return {'conj': 'and', 'nodes': [_node(node) for node in tree]} + if not isinstance(tree, dict) or set(tree) - {'conj', 'nodes'}: + raise ValueError('assistant filters must be a tree with nodes') + nodes = tree.get('nodes') + if tree.get('conj') not in ('and', 'or') or not isinstance(nodes, list) or not nodes: + raise ValueError('assistant filter tree must be a non-empty and/or tree') + return {'conj': tree['conj'], 'nodes': [_node(node) for node in nodes]} + + +# ── USER-GENERATED COLUMNS + THE FILTER CASCADE (W40-T05 / owner I16) ──────────────────────── +def user_generated_fields(module, st=None): + """Every USER-CREATED column of `module`, across EVERY stratum -- or `None` when that + cannot be established. + + ⭐⭐ OWNER I16 — *"Permission Filters must be able to filter on user-generated Fields too. + If the field is deleted, its permission filter goes with it."* The permission editor built + its pickers from the STATIC contract (`aios_grid.FIELDS`, the product JSON), so a column a + USER made was invisible to the admin choosing what to filter on. This is the half that finds + them; `prune_filter_to_fields` below is the half that lets one go. + + ⛔⛔ `None` IS NOT `[]`, AND THE DIFFERENCE IS A SILENTLY WIDENED WALL. `[]` means + "resolved: this database has no user columns". `None` means "the vocabulary could not be + read". The cascade prunes a filter leaf naming a column that is NOT in this list, so a + DEGRADED answer would DELETE a live permission rule the moment the store was busy — + permanently, silently, and in the widening direction. Every unresolvable path therefore + answers `None` and every caller declines to prune on it. This is the GET/PUT skew + `routes_admin._clean_perms`' metric-tick note records one door over, except that there the + failure mode was a REFUSAL and here it would be a REVOCATION. + + ⚠ THE UNION OF EVERY STRATUM, DELIBERATELY. `_table_workspace` is + `{username: {views, fields, overlays}, '__shared__': {...}}` and a column lives in exactly + one of them. `_module_fields`' own docstring says this list is *"the admin choosing what to + hide"* and the SUBJECT USER IS NOT A PARAMETER OF IT, so a per-user read would make a column + unpickable for the very user who owns it. + + ⛔⛔ AND THE TENANT-WIDE STRATUM MOVED OUT OF THAT DOCUMENT, WHICH IS HOW "EVERY STRATUM" + STOPPED BEING TRUE. `core/shared_overlay.py`'s own RESIDENCY note says it: a SHARED column + lives in `_table_workspace__shared`, *"its own bucket, beside the per-user one — never + a `__shared__` member"*, and `field_permissions.promote_field` POPS the definition out of + the creator's stratum once it is promoted. So the loop below, reading one document, saw + exactly the columns nobody had shared. Measured on the live tenant: 21 of the 22 custom + columns on these two grids carry `source: "overlay", shared: true` — i.e. the function + returned the ONE column it was least useful for. + + ⛔ THE CONSEQUENCE WAS NOT A MISSING PICKER ROW, IT WAS A SILENT REVOCATION. This list is + also `routes_admin._prunable_vocabulary`'s answer, and `prune_filter_to_fields` DELETES a + leaf naming a column outside it. A wall stored against a shared column would therefore have + had its leaf pruned on the next read of the record — permanently, silently, and in the + widening direction, which is the exact failure the `None`-is-not-`[]` note above exists to + prevent, arriving through the door this function opens. + + ⚠ A SHARED READ THAT RAISES ANSWERS `None`, LIKE THE PER-USER ONE; an ABSENT shared bucket + is "nothing has been shared here yet" and does NOT poison the answer. Both arms are + deliberate: the first keeps the vocabulary honest under contention, and the second is what + stops a tenant that has never shared a column from losing the per-user half of I16. + + ⛔ THE BUCKET NAME IS `view_templates.workspace_key`, NEVER SPELLED. `customer_data`'s bucket + is `customer_table_workspace` — the module key is NOT the storage key — and `_WS_KEYS` is + already the one place that mapping lives ([[one-question-two-normalizers]]). + + ⛔ THE SHAPE COMES FROM `aios_grid.fields_from_workspace`, NEVER HAND-BUILT. That is the + function the GRID overlays a saved stratum with, so a column reaches the permission editor + described exactly as the user sees it — including `filterable: False` on a `measure_` column, + whose value is host-computed into `derived` and never sits on a row. A hand-shaped dict here + would be a second idea of what a field is, and the first thing it would lose is that flag. + `fields_base=[]` makes the base loop a no-op, so what comes back is the SAVED stratum alone. + + ⚠ `scope_key=None` DROPS A COHORT-SCOPED COLUMN, AND THAT IS THE ANSWER RATHER THAN A GAP. + `grid_events` writes `scope: 'cohort'` on a column created from the Cohort surface, and such + a column is not on the Customer grid's rows at all. Offering it to the permanent filter would + admit a leaf that can only ever DENY every row — the exact reason `_metric_fields` marks a + metric pseudo-field unfilterable. The narrow read is the honest one here. + """ + if st is None: + return None + try: + import aios_grid + import core.shared_overlay as shared_overlay + import core.view_templates as view_templates + except Exception: # noqa: BLE001 + return None + bucket = view_templates.workspace_key(str(module or '')) + if not bucket: + return None # a SURFACE, or nothing this layer knows as a table + try: + doc = st.get(bucket) + except Exception: # noqa: BLE001 + return None + if not isinstance(doc, dict): + # ⛔ AN ABSENT BUCKET IS `None`, NOT `[]`. A store that cannot answer and a database + # nobody has opened are indistinguishable from here, and only one of them is safe to + # prune against. Declining costs nothing real: a workspace with no bucket has no column + # to have deleted either. + return None + # ⛔ READ THROUGH `st` RATHER THAN `shared_overlay.fields()`, AND ONLY THE NAME COMES FROM + # THAT MODULE. `shared_overlay._read` swallows every exception and answers `{}`, so an + # unreadable store would be indistinguishable from "nothing is shared" — which is precisely + # the `None`-collapsed-into-`[]` this function refuses everywhere else. `bucket()` is still + # the ONE spelling of the key, so there is no second naming convention to get wrong. + try: + shared_doc = st.get(shared_overlay.bucket(bucket)) + except Exception: # noqa: BLE001 + return None + strata = list(doc.values()) + if isinstance(shared_doc, dict): + strata.append(shared_doc) + out, seen = [], set() + for blob in strata: + if not isinstance(blob, dict): + continue + saved = blob.get('fields') + if not isinstance(saved, dict) or not saved: + continue + try: + got = aios_grid.fields_from_workspace({'fields': saved}, fields_base=[]) + except Exception: # noqa: BLE001 + # One malformed stratum must not make the whole vocabulary unresolvable — that would + # turn a bad saved field into a frozen cascade. Skip it; the other strata still count. + continue + for f in got or (): + key = f.get('key') if isinstance(f, dict) else None + if not key or key in seen: + continue + seen.add(key) + out.append(f) + return out + + +def wallable_overlay_keys(module, st=None, defs=None): + """`{key: Field}` — the user-generated columns of `module` a PERMANENT FILTER can be + evaluated against on the server. Owner I16, narrowed to what is true rather than to what is + offered. + + ⭐⭐ THE DEFINITION IS "WHOSE VALUES `shared_overlay.cells` SERVES", AND EVERY OTHER PROPERTY + FOLLOWS FROM IT. `_enrich_for_wall` merges these keys onto the rows from exactly one read of + exactly this bucket, so defining the set any other way would let it name a column whose value + the merge cannot supply — and a blank merged for a column the store cannot really answer + turns `is not X` and `is empty` from "denies every row" into "admits every row". The set and + the values therefore come from ONE stratum, by construction rather than by care. + + ⛔ SO A `formula`, `created_time` OR `measure_*` COLUMN CAN NEVER BE IN HERE, AND IT IS + `aios_grid.fields_from_workspace` THAT SAYS SO RATHER THAN A LIST OF TYPE NAMES. That is the + normaliser the GRID itself overlays a saved stratum with: it emits the read-only user pair + and the measure columns as `source: 'odoo', derived: True` (their values are computed in the + BROWSER, or from the measure catalogue, and never sit on a stored row) and an editable + overlay column as `source: 'overlay'` with no `derived` at all. Testing the two flags is + testing the grid's own declaration; a hand-built type list here would be a second idea of + what a field is, and the first thing it would lose is the next read-only kind somebody adds. + + ⛔ AND A PER-USER PRIVATE OVERLAY COLUMN IS OUT TOO, which is the part that looks like a gap + and is not. Its values ARE server-readable (`[username]['overlays']`), but they are + readable only for the SUBJECT of the wall — an account that may edit that column freely, and + would therefore be one cell edit away from walking out of its own permission wall. The admin + who wrote the rule cannot even see the values. `routes_admin._row_wall_blind_keys` keeps + refusing those at the write door, where a person is present to choose a shared column + instead. + + ⚠ `{}` ON ANY FAILURE, NEVER `None` AND NEVER A PARTIAL SET. This is read by a wall, and the + only safe degraded answer for a wall is "I can answer nothing extra" — which leaves `permits` + denying an unresolvable leaf exactly as it does today. A half-resolved set could ADMIT a row, + which is the one direction this must never fail in. + + ⛔ AND IT ANSWERS `{}` FOR A `ut_*` DATABASE — DELIBERATELY, NOT BY ACCIDENT OF THE KEY. The + two topic grids name their shared stratum off the WORKSPACE key + (`customer_table_workspace__shared`), while a user table names its own off the TABLE key + (`routes_tables._ut_shared_fields` reads `shared_overlay.fields(table_key)`, i.e. + `ut_leads__shared`, not `ut_leads_table_workspace__shared`). Deriving from `workspace_key` + therefore finds nothing on a `ut_*` key, and that is the right answer TODAY rather than a + gap to paper over: a `ut_*` wall is validated by `routes_admin._module_fields` against the + database's own stored DEFINITION, which the enforcement path already declares and whose rows + already carry the values — so there is nothing for this to add, and the permission editor + cannot offer a `ut_*` shared column in the first place. Wiring that stratum in is a change + to what an admin may WALL ON, and it belongs with the picker change that offers it. + """ + if st is None: + return {} + try: + import aios_grid + import core.shared_overlay as shared_overlay + import core.view_templates as view_templates + + bucket = view_templates.workspace_key(str(module or '')) + if not bucket: + return {} + # ⭐ `defs` IS THE CALLER'S SNAPSHOT, AND PASSING IT IS NOT AN OPTIMISATION. + # `_enrich_for_wall` derives the VALUES from one read of this bucket and the + # answerable SET from this function; taking a second read here would let the + # two disagree, and a set that names a key the values cannot serve is exactly + # how a blank gets merged and `is not` flips from deny-all to admit-all. See + # `shared_overlay.snapshot`. + doc = ({'fields': dict(defs)} if defs is not None + else st.get(shared_overlay.bucket(bucket))) + saved = doc.get('fields') if isinstance(doc, dict) else None + if not isinstance(saved, dict) or not saved: + return {} + out = {} + for f in (aios_grid.fields_from_workspace({'fields': saved}, fields_base=[]) or ()): + if not isinstance(f, dict) or not f.get('key'): + continue + if f.get('source') != 'overlay' or f.get('derived'): + continue + out[str(f['key'])] = f + return out + except Exception: # noqa: BLE001 + return {} + + +def prune_filter_to_fields(tree, valid_keys): + """`(tree, dropped)` — `tree` with every leaf naming a column outside `valid_keys` removed. + + ⭐⭐ THE CASCADE OWNER I16 ASKS FOR: *"If the field is deleted, its permission filter goes + with it."* A stored permanent filter naming a column the database no longer has is not + ignored by the wall — `apply_row_scope` uses `permits`, which DENIES anything it cannot + answer — so a deleted column silently converts that account's grid to zero rows. Dropping + the leaf is what the owner accepted instead. + + ⛔⛔ THIS IS AN ADMIN-DOOR OPERATION AND IT MUST NEVER MOVE INTO THE ENFORCEMENT PATH. + `verify_perm_scope`'s row-scope leg asserts, in these words, *"a wall naming a DELETED column + denies every row (never ignored)"* — it puts `{'colId': 'ghost', ...}` in a stored filter and + calls `apply_row_scope` directly. If the wall started ignoring unknown leaves, that gate would + flip in the FAIL-OPEN direction and a real permission wall would quietly stop walling. So the + prune runs where a RECORD is read or written, and the wall keeps denying as the backstop for + anything the prune has not reached yet. + + ⛔ PER LEAF, WHERE `routes_admin._prune_to_module` IS ALL-OR-NOTHING PER GROUP, AND THE + DIFFERENCE IS DELIBERATE. That function folds a LEGACY wall into a module that may not be + able to evaluate it, where half a group is a rule nobody wrote. This one answers a different + question: one named column is GONE, and the owner's instruction is that its leaf goes with it + while the rest of the admin's rule stands. `done-when` says so in as many words -- *"leaving + the user's other filters intact"*. + + ⚠ AND THE WIDENING IS REAL, SO IT IS STATED RATHER THAN BURIED. Dropping a leaf from an `and` + group WIDENS that wall, and dropping the last leaf anywhere removes it altogether. That is + the direction the owner chose over deny-every-row; it is also why `valid_keys` must be a + RESOLVED vocabulary (see `user_generated_fields`) and never a degraded one. + + ⚠ A LEAF'S `rhs` COUNTS AS NAMING A COLUMN. A column-to-column comparison whose right side + was deleted is just as stale as one whose left side was, and `clean_filter_tree` would answer + it by dropping the `rhs` alone -- turning "revenue > forecast" into a comparison against a + literal, which is a DIFFERENT question wearing the original's shape. + + ⛔ AN EMPTIED TREE IS `None`, NEVER `{'conj': 'and', 'nodes': []}`. Measured: `permits` + returns True on an empty node list, so an empty-but-present tree admits every row -- while + `wall_declared` and `row_scope_applies` both read it as TRUTHY and answer that a wall applies. + A door would then build rows to filter them against nothing, and an editor would paint a rule + that is not there. `None` is the one shape all three agree about. + """ + valid = {str(k) for k in (valid_keys or ())} + if not valid: + # ⛔ A FLOOR, NOT AN OPTIMISATION. An empty vocabulary would prune EVERY leaf, which for a + # module whose schema momentarily failed to resolve is the whole wall gone in one read. + return tree, [] + dropped = [] + + def _keep(node): + if not isinstance(node, dict): + return None + kids = node.get('children') + if isinstance(kids, list): + surviving = [k for k in (_keep(k) for k in kids) if k is not None] + # An emptied GROUP carries no meaning once persisted -- the same rule + # `clean_filter_tree` applies to one it built. + return dict(node, children=surviving) if surviving else None + named = [node.get('colId')] + rhs = node.get('rhs') + if isinstance(rhs, dict) and rhs.get('kind') != 'stat' and rhs.get('colId') is not None: + named.append(rhs.get('colId')) + stale = [str(c) for c in named if c is not None and str(c) not in valid] + if stale: + dropped.extend(stale) + return None + return node + + if not isinstance(tree, dict): + return tree, [] + nodes = tree.get('nodes') + if not isinstance(nodes, list): + return tree, [] + kept = [n for n in (_keep(n) for n in nodes) if n is not None] + if not dropped: + return tree, [] # unchanged by construction, not merely equal + if not kept: + return None, sorted(set(dropped)) + return {'conj': 'or' if tree.get('conj') == 'or' else 'and', 'nodes': kept}, \ + sorted(set(dropped)) + + +# ── 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 ` 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 + + +# ── C1: THE ONE DOOR TO ANY DATABASE'S ROWS (wave 36, W36-T20) ──────────────────────────────── +#: ⭐⭐ OWNER RULING R6, AND IT IS WHY THIS SECTION EXISTS AT ALL: *"EVERY database gets the same +#: permission logic, always"* — per-user field visibility AND row filtration on every database +#: carrying a unique id, whatever created it, with a NEW database inheriting it by construction +#: rather than by a list somebody maintains. +#: +#: ⛔ THE PRODUCT HAD TWO PERMISSION SYSTEMS AND ONLY ONE WAS ARMED. Everything above this line +#: walls the REGISTRY topics (`customer_data`, `product_data`) and is called only from the topic +#: assemblies. Every OTHER database is a `ut_*` table walled by `user_tables.may_open` alone — +#: creator, admin, or a `core.shares` grant — which is a BINARY door: you see all 31,418 rows of +#: `ut_odoo_invoices` or none of them. `perms.tenant_governable_modules`' docstring booked this +#: work in as many words (*"Arming `perm_scope` over `ut_*` … booked, not faked"*), and owner +#: item 11 is that booking coming due. +#: +#: ⚠ AND THE PREMISE THE GRILL GOT WRONG, because the fix depends on it: those databases are NOT +#: user-created. Ten of them (`ut_odoo_invoices`, `…_orders`, `…_agents`, `…_accounts`, `…_bills`, +#: `…_vendors`, `…_order_lines`, `…_gl_lines`, `…_customers`, `…_products`) are generated by the +#: KEYCHAIN connector (`aios-web/api/odoo_relational.py`). **`ut_` is a storage prefix, not a +#: statement about origin**, and a wall keyed off it was reading a naming artefact as a security +#: boundary. +#: +#: ⛔⛔ THE TWO QUESTIONS STAY TWO QUESTIONS. `may_open` answers *"IF you see this database"* and +#: is untouched by this section; C1 answers *"WHICH rows and fields"*. `may_read` below COMPOSES +#: them — it calls `may_open`, it does not reimplement it — because merging them is how this +#: codebase got two ideas of who owns a table once already (`user_tables.may_open`'s own wave-20 +#: note). One resolver per question, asked in order. + + +class UnknownTable(LookupError): + """No database in this tenant answers to that key. + + ⛔ RAISED, NEVER RETURNED AS AN EMPTY LIST (contract C1). An empty list reads as *"this + database is empty"* — indistinguishable from a real empty table, and the caller least able to + notice is the one that wanted rows. This repo has shipped that exact silent-empty answer + before (`user_tables.all_defs`' own correction note; [[empty-answer-vs-unfinished-answer]]). + """ + + +class Denied(PermissionError): + """This principal may not read this database at all. The IF question, answered by `may_read`.""" + + +class Unresolvable(RuntimeError): + """The rows exist and cannot be served under this call's constraints — R6's SECOND SENTENCE. + + ⭐ STANDING RULE 1 IS TWO SENTENCES AND THE SECOND IS THE HALF THAT GETS DROPPED: *"if there + is lag or it can't be done, you need to explicitly tell me why and recommend a fix"*. So a + limit that genuinely cannot be removed is REPORTED with its cause and a recommendation, never + silently enforced as a short answer. Carries the same four keys + `routes_tables._PID_SCOPE_LIMIT` already puts on the wire, so a route can hand this straight + to a client without a second vocabulary ([[one-question-two-normalizers]]). + """ + + def __init__(self, subject, effect, cause, recommendation): + self.subject, self.effect = subject, effect + self.cause, self.recommendation = cause, recommendation + super().__init__(f"{subject}: {effect}. {cause}. {recommendation}") + + def as_limit(self): + """The dict shape `routes_tables` puts in an assembly's `limits` list.""" + return {"subject": self.subject, "effect": self.effect, + "cause": self.cause, "recommendation": self.recommendation} + + +#: Row readers DECLARED by the app layer, keyed by EXACT database key. +#: `reader(table_key, user, st) -> (fields, rows)`. +#: +#: ⛔ WHY A REGISTRY AND NOT AN IMPORT. `core` never imports up (`platform/ARCHITECTURE.md`), and +#: a registry TOPIC's rows are built by `modules/` + `aios_grid` behind an API-layer pool cache +#: (`routes_customers._pool_for`), which is two layers above this file. Same idiom `user_tables` +#: already uses for exactly this reason — `register_connected`, `register_read_through`, +#: `ROW_HOOKS`: *"`core` never imports up, so the app tells this layer rather than being +#: interrogated by it."* +_ROW_SOURCES = {} + +#: THE reader for a read-through `ut_*` grid — one reader, because there is one mirror. +#: `reader(table_key, field_keys, st) -> rows`. +_MIRROR_READER = None + + +def register_rows(reader, *table_keys): + """Declare who reads a NAMED database's rows. Returns the registered key set. + + ⚠ The return value is the registrar's own answer on purpose: a public function whose only + caller is a `verify_*.py` file is a feature no user can reach, and this repo has a gate that + says so ([[reachable-is-not-the-same-as-built]]). Routing the read door through the write + door's return keeps one construction site of the set instead of two. + """ + for key in table_keys: + k = str(key or '').strip() + if k: + _ROW_SOURCES[k] = reader + return frozenset(_ROW_SOURCES) + + +def register_mirror(reader): + """Declare THE reader for read-through `ut_*` grids (`routes_tables._read_through_rows`).""" + global _MIRROR_READER + _MIRROR_READER = reader + return _MIRROR_READER is not None + + +#: ⛔ `row_sources()` IS DELETED (W36-T24 / owner item 13), AND THE REASON IS THE ONE THIS WAVE +#: KEEPS FINDING. It returned `frozenset(_ROW_SOURCES)` under a docstring calling itself *"The ONE +#: list to read"* — and `register_rows` ALREADY returns exactly that, which is the same idiom +#: `user_tables.register_connected_prefix` uses and the same reason: routing the read door through +#: the write door's return keeps ONE construction site of the set. A second accessor beside it is a +#: parallel path with nothing of its own to say, and it shipped with no caller outside `verify_*.py` +#: — the shape that is whole, correct and unreachable ([[artifact-with-no-importer]]; reported by +#: the integrator's `web_reachability` pass, `mailbox/A.md` A-43). The registrar's return is the +#: read: `routes_grid._C1_ROW_SOURCES` is that value, held where it is registered. + + +def _ut(): + import core.user_tables as user_tables + return user_tables + + +def may_read(user, table_key, st=None): + """May this principal read `table_key` AT ALL — the IF question, on EVERY database. + + ⛔ COMPOSED, NOT RE-DERIVED, and the order is the whole rule: + + 1. an admin reads everything (break-glass — `deps._user_for` hands back a hardcoded master + dict on a store outage and it will never carry a `perms` block); + 2. an EXPLICIT stored `access: false` DENIES, on any database. This is the toggle owner + item 11 asks for, and it is a **deny-only overlay**: it may revoke a database the wall + below would admit, and it may never grant one that wall refuses; + 3. a `ut_*` database defers to `user_tables.may_open` — creator, admin, or a `core.shares` + grant — UNMODIFIED. W36-T21: *"`may_open` still decides IF the database is visible."* + 4. anything else is a registry topic and defers to `may_access` above. + + ⛔⛔ WHY ABSENCE MUST NOT DENY ON A `ut_*` KEY, which is the opposite of what leg 4 does. + `may_access` reads migrated-and-undeclared as DENY — correct for a topic, because + `routes_admin` writes an entry for every governable topic on every save. ⚠ NO `ut_*` ENTRY WAS + STORABLE AT ALL UNTIL W36-T22 — `_clean_perms` refused the key with `unenforced_module` — so + every record migrated before this wave carries no entry for any of them, and reading that + absence as a decision would revoke all ten keychain databases from every migrated account the + moment this arms. That is not R6, it is an outage. Leg 3 therefore asks the wall that HAS been + answering rather than the marker that has not, and it keeps being right AFTER the flag's + deletion: an admin who has never opened the editor for a database has still not decided + anything about it. + + ⛔⛔ PASS THE **PUBLIC** RECORD, NOT THE ONE OUT OF `users.json`. Leg 3 needs a username, and a + stored record is keyed BY username in that bucket and does not carry one INSIDE it — only + `core.users._public(uname, rec)` puts it there, which is what `deps.Session.user` holds. Hand + this the raw record and `may_open` gets a `None` viewer and fail-closes, so every `ut_*` + database reads as DENIED for an account that can open all of them. It fails in the SAFE + direction and is silently wrong, which is the worst pair to debug — it cost two call sites in + one afternoon: a gate double, and `routes_admin.get_perms`' own fix for this very outage. + """ + if perms.is_admin(user): + return True + e = entry(user, table_key) + if e is not None and not bool(e.get('access', True)): + return False + key = str(table_key or '') + if key.startswith(_ut().KEY_PREFIX): + return bool(_ut().may_open(key, (user or {}).get('username'), False, st=st)) + return may_access(user, table_key) + + +def wall_declared(user, table_key): + """Is a ROW or FIELD narrowing declared for this principal on this database? + + ⛔ THE QUESTION A DOOR ASKS BEFORE SERVING ROWS IT CANNOT SCOPE. `perms.py` warned that a + stored `ut_*` wall would be INERT — *"the editor would say DENY, the table routes would keep + serving, and nothing anywhere would say so"*. A route that cannot apply C1 must therefore + REFUSE for a principal this returns True for, rather than serve the whole database. False for + an admin (they bypass the wall entirely) and for any record with no entry, so a door asking + this pays nothing and changes nothing for everybody who has no wall. + """ + if perms.is_admin(user): + return False + e = entry(user, table_key) + if not e: + return False + return bool(e.get('filter')) or bool(e.get('hiddenFields')) + + +def row_scope_applies(user, table_key): + """Does a permanent ROW filter narrow this principal on this database? + + ⚠ `wall_declared`'s narrower half, and it exists so a rows-free caller can SKIP building rows + it would only need in order to filter them. `routes_tables.scoped_pids` is that caller: its + whole point is that the pid set costs no row pass, and paying for one on every database + switch — for every account, walled or not — would undo W30-T30 to enforce a rule that applies + to almost nobody. Asked here rather than spelled out at the call site, so there is ONE + statement of when the row wall bites ([[one-question-two-normalizers]]). + """ + if perms.is_admin(user): + return False + return bool((entry(user, table_key) or {}).get('filter')) + + +def scoped_table(user, table_key, st=None, ctx=None): + """⭐⭐ CONTRACT C1 — the rows of ANY database, already field-stripped and row-filtered for + `user`. Registry topic or `ut_*`; there is no third kind and no per-database branch. + + rows = scoped_table(user, 'ut_odoo_invoices') # a keychain database + rows = scoped_table(user, 'customer_data') # a registry topic + + `user` is a user RECORD (the dict `deps.Session.user` carries), not a username — the whole + wall is a pure function of that record. BOTH arguments are positional and REQUIRED: a caller + that forgets the principal must not run, because the only thing a defaulted one could mean is + "unscoped", which is the widening direction. + + ⛔ FAIL-CLOSED, THREE WAYS, AND EACH IS A DIFFERENT EXCEPTION so a caller can answer with the + right status instead of guessing: `UnknownTable` (no such database — never an empty list), + `Denied` (the IF question said no), `Unresolvable` (the rows cannot be served and here is + why — standing rule 1's second sentence). + + ⚠ NO CAP. A connected source is read THROUGH the mirror in full (standing rule 1); the only + thing that stops it is a population that exceeds one materialisation window, and that arrives + as `Unresolvable` carrying its cause and a recommendation rather than as a short answer. + + ⭐ E's SCRIPT SANDBOX HOLDS NO SECOND PATH TO THE STORE (wiring W1), which is why this is + THE door rather than A door: everything a sandboxed script may read, it reads here, under the + CALLING user's scope (R5). + """ + _fields, rows = _scoped(user, table_key, st=st, ctx=ctx) + return rows + + +def scoped_fields(user, table_key, st=None): + """The COLUMNS of any database this principal may see — C1's other half. + + ⛔ IT IS NOT A CONVENIENCE, IT IS THE SECOND WIRE. `strip_row`'s own note above says it: the + field list and the row payload are two different wires, and narrowing one without the other + leaves the value sitting where anything can read it. A caller that must render a scoped table + needs both, and E cannot read a `ut_*` definition to learn its columns — the sandbox has no + second path to the store (W1). So both come from here, off one wall. + + ⚠ On a `ut_*` database this reads the DEFINITION only — the projection, no rows (D-213). On a + registry topic it goes through the registered reader, which builds that topic's pool; the + pool is cached per scope on the tenant runtime, so it is a cache hit next to `scoped_table`. + """ + fields, _rows = _scoped(user, table_key, st=st, ctx=None, want_rows=False) + return fields + + +def _scoped(user, table_key, st=None, ctx=None, want_rows=True): + """`(fields, rows)` — ONE evaluator behind both public doors, so they cannot disagree.""" + key = str(table_key or '').strip() + if not key: + raise UnknownTable('a database key is required. This door will not guess which database ' + 'was meant') + if not may_read(user, key, st=st): + raise Denied(f"this account may not read '{key}'") + fields, rows = _read(key, user, st, want_rows) + # THE FIELD WALL — a TRANSITIVE closure, so hiding a column also hides every formula computed + # FROM it. Resolved ONCE and used for both wires; see `hidden_keys` for why a set difference + # is the wrong shape here. + # ⭐ W38-T16 — `st` RIDES INTO THE WALL, not just into the read. The field-grant leg resolves + # against `object_shares` in THIS tenant's store; without the handle it would answer from the + # module default (tenant #0) and hide a grantee's own column on every other tenant. + hide = hidden_keys(user, key, fields, st=st) + if not want_rows: + return (visible_fields(fields, user, key, st=st) if hide else fields), [] + # THE ROW WALL — `permits()`, so a permanent filter this evaluator cannot answer DENIES + # rather than being ignored. Evaluated against the UNSTRIPPED contract on purpose: a + # permanent filter may name a column the reader is not allowed to SEE, and dropping the + # predicate would widen the read rather than narrow it. + # ⭐ OWNER I16 — `st` RIDES INTO THE ROW WALL TOO, and this door is the one that already had + # the handle and simply did not pass it down. The field wall two lines up has taken it since + # W38-T16 for the same reason: a wall resolved without a tenant handle answers from the + # module default, and here that means a user-generated column reads as unanswerable and + # denies every row. + rows = apply_row_scope(rows, user, key, fields, ctx, st=st) + if hide: + fields = visible_fields(fields, user, key, st=st) + rows = [strip_row(r, hide) for r in rows] + return fields, rows + + +def _read(table_key, user, st, want_rows=True): + """`(fields, rows)` BEFORE the wall — the app layer's reader, or core's own for a `ut_*`.""" + reader = _ROW_SOURCES.get(table_key) + if reader is not None: + fields, rows = reader(table_key, user, st) + return list(fields or ()), list(rows or ()) + ut = _ut() + if not table_key.startswith(ut.KEY_PREFIX): + # ⛔ A TOPIC WITH NO REGISTERED READER IS UNKNOWN, NOT EMPTY. In a process that never + # imported the API layer this is the honest answer: nothing here can build that pool. + raise UnknownTable(f"no database named '{table_key}' in this workspace, and no reader " + f"is registered for it") + return _read_user_table(table_key, st, want_rows) + + +def _read_user_table(table_key, st, want_rows=True): + """core's OWN reader for a `ut_*` database. Answers with NO registrar, deliberately. + + ⭐ WHY IT LIVES IN `core` RATHER THAN BEING REGISTERED LIKE THE TOPICS, and it is the same + argument that seeds `user_tables._CONNECTED_PREFIXES` rather than registering it: a cold + process — E's sandbox subprocess, a worker, a gate — that never imported an API route still + owes the right answer for `ut_odoo_invoices`. A registrar-only design would raise there, and + the sandbox is exactly such a process. + + ⚠ THE WALL IS ANSWERED ON A PROJECTION AND THE ROWS ARE NOT. `lend_defs` serves definitions + without the 28.6 MB of rows (D-213), which is every read this function makes when + `want_rows` is false; a projected document RAISES on `rows` rather than answering empty, so + the materialised arm below takes the whole read explicitly. + """ + ut = _ut() + lent = ut.lend_defs(st) + defn = ut.get(table_key, st=lent) + if not defn: + raise UnknownTable(f"no database named '{table_key}'") + fields = [dict(f) for f in (defn.get('fields') or [])] + if not want_rows: + return fields, [] + if not ut.materialises(table_key, st=st, defn=defn): + # A read-through grid stores no rows here — they live in the mirror, and reading + # `defn['rows']` would find an empty dict and serve an EMPTY GRID: correct-looking, + # wrong, and silent. + if _MIRROR_READER is None: + raise Unresolvable( + subject='rows', effect='unreadable', + cause=(f"'{table_key}' is served read-through from the connector mirror and no " + f'mirror reader is registered in this process'), + recommendation=('call `perm_scope.register_mirror(...)` from the app layer before ' + 'reading a read-through database, or read it through the API')) + keys = {f['key'] for f in fields if f.get('key')} + return fields, list(_MIRROR_READER(table_key, keys, st) or ()) + whole = ut.get(table_key, st=st) + if whole is None: + # Deleted between the wall and here. The same refusal, not an empty table. + raise UnknownTable(f"no database named '{table_key}'") + field_keys = {f['key'] for f in fields if f.get('key')} + rows = [] + for rid, row in (whole.get('rows') or {}).items(): + if not str(rid).isdigit(): + continue + r = {k: v for k, v in (row or {}).items() if k in field_keys} + r['pid'] = int(rid) + rows.append(r) + rows.sort(key=lambda r: r['pid']) + return fields, rows + + +# ── C10 / R12: THE PIVOT WALL — BOTH SIDES OF ONE JOIN (wave 41, W41-T16) ───────────────────── +#: ⭐⭐ A PIVOT MUST NEVER BECOME A WAY TO READ A DATABASE YOU ARE WALLED OUT OF. R10's pivot set +#: is "products ordered by the filtered customers": one request, TWO databases, and the account +#: that made it holds a SEPARATE verdict on each. Everything above this line answers for one +#: database at a time, so a caller wiring C10 by hand would naturally wall the side it was already +#: looking at and let the other one through — which is exactly the read the source-side wall was +#: installed to prevent, arriving through a door built after it. +#: +#: ⛔ AND THE FAILURE IS SILENT IN THE WORST DIRECTION. A pivot that answers a walled-out reader +#: with `rows: []` is indistinguishable from a pivot answering "this customer ordered nothing": +#: the reader draws the BUSINESS conclusion, not the permission one, and nothing anywhere says +#: otherwise. That is why C10 carries `refusal` as a first-class response key instead of letting +#: an empty grid stand in for one, and why the refusal names WHICH database refused rather than +#: answering a bare boolean ([[empty-answer-vs-unfinished-answer]]). +#: +#: ⛔ R12 — RELATIONAL SCOPE IS EXACTLY TWO DATABASES THIS WAVE: *"the permission wall applied on +#: BOTH sides of the join. ⛔ NOT chaining to a third database."* A third hop is refused here BY +#: NAME rather than left undefined, because "undefined" in a wall means whatever the first caller +#: happens to do, and the first caller is in another lane. +#: +#: ⚠ THIS IS THE SERVER HALF OF A CLIENT FEATURE THAT DOES NOT EXIST YET. `pivot_scope` is the +#: only door; a route that resolves one side itself has re-derived half a wall, which is how this +#: codebase got two ideas of who owns a table once already (`may_read`'s note above). + +#: R12's path length, stated ONCE so the refusal and the response envelope cannot disagree about +#: what "a pivot" is. +PIVOT_PATH_LEN = 2 + + +class PivotScope: + """⭐⭐ CONTRACT C10 — the verdict on ONE pivot, with BOTH sides of the join already resolved. + + Built by `pivot_scope()` and by nothing else: a hand-built one is a verdict nobody asked the + wall for. + + It holds the PRINCIPAL it was resolved for, and that is the property which makes it safe to + pass around. `source_grid`/`target_grid` cannot be handed a different user than the one the + verdict was computed against, so a route cannot resolve the scope for the caller and then + filter rows for somebody else. + + ⛔ ON A REFUSAL, `source_scope` AND `target_scope` ARE `None`, NOT `(None, None)`. The tuple + form is a legitimate answer meaning "no narrowing" — the WIDEST scope — so handing it back for + a database this account may not read at all would let a caller that ignored the refusal unpack + a pass. `None` raises on unpacking instead, which is the direction this file fails in + everywhere else. + + `code` is SERVER-SIDE ONLY and never reaches the wire; C10's refusal is `{reason, subject}` + and exactly those two keys. The closed set a route may branch on is: + `no_database` (a side was not named) · `not_relational` (that key is not a joinable database) + · `chained` (R12: a third hop) · `unreadable` (the wall said no) · `unresolvable` (the wall + could not be asked, so it refuses). A route wanting a status: `unreadable` is 403, + `no_database`/`not_relational`/`chained` are 400, `unresolvable` is 401 when `subject` is + `'session'` and 503 otherwise. ⚠ But C10's answer is a 200 carrying `refusal` — a status code + is a SECOND channel, and only one of them renders the sentence the reader needs. + """ + + __slots__ = ('user', 'source', 'target', 'path', 'code', 'reason', 'subject', + 'source_scope', 'target_scope') + + def __init__(self, user, source, target, path, + code=None, reason='', subject='', source_scope=None, target_scope=None): + self.user, self.source, self.target, self.path = user, source, target, list(path or ()) + self.code, self.reason, self.subject = code, reason, subject + self.source_scope, self.target_scope = source_scope, target_scope + + @property + def permitted(self): + """May this pivot be served at all? False exactly when `refusal()` has something to say.""" + return self.code is None + + def refusal(self): + """C10's `refusal` value: `{reason, subject}`, or None when the pivot is permitted. + + `subject` is the DATABASE KEY that refused, so a client can say which side of the join + stopped it instead of blaming the pivot as a whole. Two refusals have no key to give and + must not borrow an innocent one: a side the request never named answers with that side's + name (`'source'` / `'target'`), and a pivot with no identifiable principal answers + `'session'`. Naming a database in either case would point the reader at the wrong thing to + go fix. + """ + if self.code is None: + return None + return {'reason': self.reason, 'subject': self.subject} + + def limits(self, st=None): + """⭐ STANDING RULE 1's SECOND SENTENCE on this path: every `limit_report()` in force on + either side, as a list, in one vocabulary and no second one. + + ⛔ THIS WALL APPLIES NO CEILING OF ITS OWN AND NEVER WILL. A pivot narrows rows by + PERMISSION, and a permission answer that silently also truncated would understate every + related-record count it feeds while looking complete. What it does owe is the report for a + ceiling the DATABASES themselves carry, so a client renders the same limit sentence here + as on the grid rather than learning a pivot-shaped dialect ([[one-question-two-normalizers]]). + + ⚠ ASKED ONLY OF THE `ut_` NAMESPACE, AND THAT GUARD IS LOAD-BEARING, not an optimisation. + `user_tables.row_limit` resolves a registry topic key through `materialises()` -> + `get(key)` -> `{}` -> "materialised, not connected" and answers `MAX_ROWS`, so asking it + about `customer_data` INVENTS an editable-substrate ceiling for a compiled pool that has + none, and pays a whole-document copy to do it. + + Each report is `limit_report`'s own four keys plus `database`, because `subject` there is + always `'rows'` and a two-database answer must still say which side it is about. + """ + ut, out, seen = _ut(), [], set() + for key in self.path: + k = str(key or '') + if not k.startswith(ut.KEY_PREFIX) or k in seen: + continue + seen.add(k) + try: + report = ut.limit_report(k, st=st) + except Exception: # noqa: BLE001 + continue + if report: + out.append(dict(report, database=k)) + return out + + def envelope(self, st=None): + """The C10 response keys THIS wall owns, ready to merge into the route's own + `{rows, fields, sourceCount, targetCount}`. + + `path` always; `refusal` only when there IS one, because C10 spells it `refusal?` and a + `null` is not what an optional key means; `limits` only when something is reported, so the + ordinary answer is exactly the shape C10 names and it grows only when standing rule 1 has + something to say. + """ + env = {'path': list(self.path)} + refused = self.refusal() + if refused is not None: + env['refusal'] = refused + reported = self.limits(st=st) + if reported: + env['limits'] = reported + return env + + def source_grid(self, fields, rows, ctx=None, st=None): + """`(fields, rows)` of the SOURCE database, as this account may receive them.""" + return self._side(self.source, fields, rows, ctx, st) + + def target_grid(self, fields, rows, ctx=None, st=None): + """`(fields, rows)` of the TARGET database, as this account may receive them. + + ⛔ THIS IS THE SIDE THAT WOULD OTHERWISE DEFAULT OPEN. The source rows arrive already + walled, because the caller was looking at that grid; the target's rows are fetched BY the + pivot, for a database the reader may never have opened, and serving them unfiltered is the + whole hole. Same stored wall, same evaluator, same verdict `apply_row_scope` reaches at + every other door: a reader with a row filter on the target sees only their own rows here. + """ + return self._side(self.target, fields, rows, ctx, st) + + def _side(self, key, fields, rows, ctx, st): + """ONE narrower behind both sides, so the two cannot drift into different verdicts. + + ⛔⛔ BOTH WIRES COME OFF ONE CALL AND THAT IS WHY THIS RETURNS A PAIR. An earlier draft of + this method returned rows alone and told the caller to take the columns from + `scoped_fields`. That is the D-470 shape, rebuilt one wave after it was measured: the + field list arrives narrowed, the rows arrive whole, and the hidden value sits in the + payload under a column the reader cannot see — `scoped_fields()` declaring `['dba']` + beside a row carrying `custom_region_qa: 'TOP-SECRET-VALUE'`. `strip_row`'s own note is + the rule and a docstring is not enforcement, so the two wires are one return value and + cannot be taken apart by a caller in a hurry. + + Same order as `_scoped`, deliberately: `hidden_keys` -> `apply_row_scope` -> strip. ⛔ The + ROW wall is evaluated against the UNSTRIPPED contract, because a permanent filter may name + a column the reader is not allowed to SEE and dropping that predicate would WIDEN the read + rather than narrow it. And `st` goes to both halves or to neither (`visible_fields`' own + warning): lend it to one and the field list narrows by more than the rows did. + + ⛔⛔ `fields` MUST DECLARE THE USER-GENERATED COLUMNS, AND `st` MUST BE LENT. Wave-40 lane + C measured this on `apply_row_scope`: a wall naming a custom column narrows correctly only + when the field list DECLARES that column, and passing the STATIC contract instead makes + the same wall deny EVERY row (`[1]` vs `[]`, measured at `routes_customers.py` and + `routes_products.py`). On this door that failure wears the pivot's own worst costume: a + reader who may see their rows gets none, reads it as "no related records", and the wall + looks like the data. Hand this the assembled contract, not the compiled-in one. + + ⚠ THE THIRD WIRE IS `visible_overlays` AND IT IS NOT SERVED HERE. A pivot answers with + related records, not with the workspace overlay stratum; a route that ever adds overlays + to this response owes that call too, for the reason D-427 records. + + ⛔ RAISES ON A REFUSED PIVOT rather than returning `[]`. An empty list here would rebuild, + one layer down and inside the server where no client could tell the difference, the exact + "no related records" misreading `refusal` exists to prevent. + """ + if not self.permitted: + raise Denied(self.reason) + cols = list(fields or ()) + hide = hidden_keys(self.user, key, cols, st=st) + kept = apply_row_scope(rows, self.user, key, cols, ctx, st=st) + if not hide: + return cols, kept + return (visible_fields(cols, self.user, key, st=st), + [strip_row(r, hide) for r in kept]) + + +def pivot_scope(user, source, target, st=None, path=None): + """⭐⭐ CONTRACT C10 / RULING R12 — a pivot's row scope, decided on BOTH SIDES of the join. + + sc = perm_scope.pivot_scope(session.user, req.source, req.target, st=rt) + if not sc.permitted: + return {'rows': [], 'fields': [], 'sourceCount': 0, 'targetCount': 0, + **sc.envelope(st=rt)} + fields, rows = sc.target_grid(target_fields, related_rows, st=rt) + + Returns a `PivotScope` and NEVER raises for a permission answer, because C10 puts the refusal + ON THE WIRE beside the empty rows rather than throwing a status the client has to interpret. + Ask a refused verdict for rows anyway and you get `Denied`. + + THE ORDER, and every step of it fails closed: + + 1. **Both sides must be named.** A pivot missing one side is not a narrower pivot. + 2. **Both keys must be a legal relational endpoint** — `user_tables.is_linkable_target`, + C5's ONE answer, landed in this tree by W41-T15. Asked of BOTH sides rather than only the + target: a join has two endpoints, and `users`, `object_shares`, `cohort` and the archived + `customer`/`product` keys are no more joinable as a source than as a target. + 3. **R12: the path is exactly `PIVOT_PATH_LEN` databases.** A longer `path`, or one that + disagrees with `(source, target)`, is refused as a chain. + 4. **`may_read` on the SOURCE, then `may_read` on the TARGET** — C1's IF question, asked + twice, composed and never re-derived. Source first, so the `subject` a customers-only + reader gets back names the TARGET that actually stopped them. + 5. **`derive_pool_scope` on BOTH**, once the verdict is a pass, so the pool behind each side + is BUILT with the scope that side's permanent filter derives. Amendment 3's argument is + not weaker across a join, it is stronger: a target pool built consolidated hands a + Fisch-only reader products whose revenue is Fisch+Royal, and the row list looks perfectly + correct while every number on it is somebody else's. + + ⛔ AN EXCEPTION OUT OF EITHER `may_read` IS A REFUSAL — never a crash, and never a pass. + `may_read` reaches the store on a `ut_*` key, and a store outage on the target side must not + be the one condition under which the target wall is skipped. The reason is FIXED COPY: an + exception's text is a server detail and this string reaches a screen. + + ⛔⛔ AND A MISSING PRINCIPAL IS A REFUSAL BEFORE ANYTHING ELSE IS ASKED — MEASURED, not + theorised. `may_read(None, 'customer_data')` answers **True** today: `_rec(None)` is `{}`, so + the record reads as UN-MIGRATED and the legacy `perms.may_open` wall admits it, which is the + documented fail-open window `perms_v` exists to close. Every other door survives that because + it uses the principal ONCE and then serves a pool the caller already scoped. THIS door hands + the principal forward: `target_grid` would carry the `None` into `apply_row_scope`, find no + entry, find no filter, and return the target database WHOLE. So a route that lost its session + would not 401, it would pivot. `scoped_table`'s own rule stated one layer up — *"the only + thing a defaulted principal could mean is 'unscoped', which is the widening direction"* — and + this is the door with the most to lose by it. + """ + src, tgt = str(source or '').strip(), str(target or '').strip() + walk = [str(p or '').strip() for p in path] if path is not None else [src, tgt] + + def refuse(code, reason, subject): + return PivotScope(user, src, tgt, walk, code, reason, subject) + + if not isinstance(user, dict) or not user: + return refuse('unresolvable', + 'This session could not be identified, so the pivot refuses rather than ' + 'serve records it cannot scope. Sign in again and reopen it.', + 'session') + + if not src or not tgt: + return refuse('no_database', + 'A pivot needs a source database and a target database, and this request ' + 'names only one.', + 'source' if not src else 'target') + + ut = _ut() + for key in (src, tgt): + if not ut.is_linkable_target(key): + return refuse('not_relational', + f"'{key}' is not a database a pivot can join. Choose a linked database " + f'on both sides of the pivot.', + key) + + # R12, refused BY NAME so the message can point at the hop that is not supported instead of at + # the pivot the reader did ask for. + if len(walk) > PIVOT_PATH_LEN: + extra = next((k for k in walk[PIVOT_PATH_LEN:] if k), tgt) + return refuse('chained', + f'A pivot joins exactly two databases, and this one names {len(walk)}. ' + f"Open '{extra}' from the pivoted records instead of chaining to it here.", + extra) + if walk != [src, tgt]: + return refuse('chained', + 'A pivot joins exactly two databases, and the path it was given is not the ' + 'two this request names. Reopen the pivot from the database you are ' + 'looking at.', + tgt) + + for key, side in ((src, 'source'), (tgt, 'target')): + try: + allowed = bool(may_read(user, key, st=st)) + except Exception: # noqa: BLE001 + return refuse('unresolvable', + f"'{key}' could not be resolved just now, so this pivot refuses rather " + f'than serve records it cannot scope. Try again in a moment.', + key) + if allowed: + continue + if side == 'target': + reason = (f"This account is not permitted to read '{key}', so its records are " + f'withheld here. This is a permission refusal, not a database with nothing ' + f'related in it.') + else: + reason = (f"This account is not permitted to read '{key}', so a pivot cannot start " + f'from it.') + return refuse('unreadable', reason, key) + + return PivotScope(user, src, tgt, walk, + source_scope=derive_pool_scope(user, src), + target_scope=derive_pool_scope(user, tgt))