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,999 +1,999 @@ -"""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 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() - - 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 ()) - - 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} - - -# ── ROWS ───────────────────────────────────────────────────────────────────────────────────── -def apply_row_scope(rows, user, module, fields, ctx=None): - """The rows this account may receive: `filter_eval.permits` over the permanent filter. - - `permits`, never `matches` — an unanswerable permanent filter DENIES rather than being - ignored. See `harness/filter_eval`'s docstring for the field-rename walkthrough that makes - the difference a leak rather than a preference. - """ - if perms.is_admin(user): - return list(rows or ()) - e = entry(user, module) - tree = (e or {}).get('filter') - if not tree: - return list(rows or ()) - from harness import filter_eval as fe - return [r for r in (rows or ()) if fe.permits(tree, r, fields, ctx)] - - -def assistant_apply_row_scope(rows, user, module, fields, ctx=None): - """Apply an explicit Assistant grant's permanent filter without an admin bypass.""" - 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 - 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]} - - -# ── 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. - rows = apply_row_scope(rows, user, key, fields, ctx) - 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 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() + + 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 ()) + + 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} + + +# ── ROWS ───────────────────────────────────────────────────────────────────────────────────── +def apply_row_scope(rows, user, module, fields, ctx=None): + """The rows this account may receive: `filter_eval.permits` over the permanent filter. + + `permits`, never `matches` — an unanswerable permanent filter DENIES rather than being + ignored. See `harness/filter_eval`'s docstring for the field-rename walkthrough that makes + the difference a leak rather than a preference. + """ + if perms.is_admin(user): + return list(rows or ()) + e = entry(user, module) + tree = (e or {}).get('filter') + if not tree: + return list(rows or ()) + from harness import filter_eval as fe + return [r for r in (rows or ()) if fe.permits(tree, r, fields, ctx)] + + +def assistant_apply_row_scope(rows, user, module, fields, ctx=None): + """Apply an explicit Assistant grant's permanent filter without an admin bypass.""" + 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 + 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]} + + +# ── 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. + rows = apply_row_scope(rows, user, key, fields, ctx) + 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