| """core/perm_scope.py β the permission WALL for table modules (wave 15, C-PERM). |
| |
| ONE place answers the three questions a restricted account raises on a grid surface: |
| |
| may_access(user, module) may they open it at all? |
| visible_fields(fields, u, mod) which COLUMNS may they receive? |
| apply_row_scope(rows, u, mod, β¦) which ROWS may they receive? |
| |
| plus one that exists only because of how the pool is built: |
| |
| derive_pool_scope(user, module) which (team_id, agent) must the pool be BUILT with? |
| |
| Both hosts call these β `aios-web/api` (`grid_assembly`) and `app.py` (`_table_grid`) β because |
| a wall that exists on one runtime and not the other is not a wall. `core/perms.py` stays what it |
| is (module GRANTS + the legacy BU derivation); this module is the row/field/pushdown layer that |
| sits on top, and it is deliberately a separate file so the legacy readers can keep their |
| semantics untouched while this one fails closed. |
| |
| ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| THE RECORD |
| |
| user['perms'] = {'<module_key>': {'access': bool, |
| 'filter': {'conj'?: 'and'|'or', 'nodes': [...]} | None, |
| 'hiddenFields': ['<field key>', ...]}} |
| user['perms_v'] = 1 stamped by the migration and by every write |
| |
| `perms_v` is the EXPLICIT-RESOLUTION marker, and it is here because of `permissioning.md` |
| Part II gap #5: "`allowed_modules() is None` is fail-open by default β¦ make 'resolved: |
| unrestricted' an explicit value so absence/uncertainty DENIES." The same class already shipped |
| twice in this codebase (`modules: []` and `bus: [<unknown id>]` both read as UNRESTRICTED β |
| `routes_admin.py`'s own docstring documents both). So: |
| |
| * `perms_v` ABSENT β the record is UN-MIGRATED, and the LEGACY wall applies unchanged |
| (`core.perms` module grants + the `bus`/`agent` query scope). That is not fail-open: it is |
| today's real wall, and it bounds the rollout window to "until the migration runs". |
| * `perms_v == 1` and the module has NO entry β **DENY**. Absence now means what it says. |
| * `role == 'admin'` bypasses all of it β which is also what keeps BREAK-GLASS alive. |
| `deps._user_for` hands back a hardcoded master dict on a store outage |
| (`{'username':'admin','role':'admin','bus':'all','modules':'all'}`) that will never carry a |
| perms block; without this clause an explicit-marker scheme locks the owner out of their own |
| product at exactly the moment the store is broken. |
| """ |
| import re |
|
|
| import core.perms as perms |
|
|
| |
| |
| |
| _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): |
| |
| return False |
| return perms.may_open(user, module) |
|
|
|
|
| |
| def hidden_keys(user, module, fields): |
| """The TRANSITIVE closure of hidden field keys (C-PERM amendment 5). |
| |
| β WHY A CLOSURE AND NOT A SET DIFFERENCE. A formula field is computed in the BROWSER |
| (`formulaEngine.ts`, injected by `computedRows`) from `{ref}`s into other columns, and a |
| measure column's value arrives precomputed in `derived`. So hiding field X has exactly three |
| possible outcomes and only one of them is coherent: |
| |
| strip X, keep formulas β every formula over X computes blank or wrong, silently |
| keep X's value for them β X has leaked, wearing a formula's name |
| strip X AND its dependentsβ the only honest answer |
| |
| So a hidden field drags every formula that references it β and every formula that references |
| THAT formula, hence the fixpoint loop β out of the payload with it. |
| |
| β This runs on every assembly, so it is a fixpoint over a handful of custom fields, not a |
| graph library. `MAX_PASSES` bounds a reference cycle the client would refuse to evaluate |
| anyway; without it a self-referential pair would spin here. |
| """ |
| e = entry(user, module) |
| if perms.is_admin(user) or not e: |
| return frozenset() |
| hidden = {str(k) for k in (e.get('hiddenFields') or ()) if k} |
| if not hidden: |
| return frozenset() |
|
|
| refs = {} |
| for f in fields or (): |
| if not isinstance(f, dict) or not f.get('key'): |
| continue |
| expr = f.get('formula') |
| if isinstance(expr, str) and expr: |
| refs[f['key']] = {m.strip() for m in _FORMULA_REF.findall(expr) if m.strip()} |
|
|
| MAX_PASSES = 12 |
| for _ in range(MAX_PASSES): |
| grew = False |
| for key, deps in refs.items(): |
| if key not in hidden and deps & hidden: |
| hidden.add(key) |
| grew = True |
| if not grew: |
| break |
| return frozenset(hidden) |
|
|
|
|
| def visible_fields(fields, user, module): |
| """`fields` minus the hidden closure. Order preserved β the column order is the user's.""" |
| hide = hidden_keys(user, module, fields) |
| if not hide: |
| return list(fields or ()) |
| return [f for f in (fields or ()) |
| if not (isinstance(f, dict) and f.get('key') in hide)] |
|
|
|
|
| def strip_row(row, hide): |
| """Drop hidden keys from ONE assembled row. Cheap enough to run per row, and it must run |
| per row: the field list and the row payload are two different wires, and stripping only the |
| first would leave the value sitting in the second where anyone can read it.""" |
| if not hide or not isinstance(row, dict): |
| return row |
| return {k: v for k, v in row.items() if k not in hide} |
|
|
|
|
| |
| 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)] |
|
|
|
|
| |
| |
| |
| |
| _DBA_TEAM = ((frozenset({'fisch', 'both'}), 5), (frozenset({'royal', 'both'}), 6)) |
|
|
| |
| |
| |
| _PUSHDOWN_OPS = frozenset({'eq'}) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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: |
| |
| |
| 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): |
| |
| |
| |
| 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: |
| |
| team_id = tid if team_id in (None, tid) else None |
| break |
| elif col == 'agent': |
| v = str(raw or '').strip() |
| |
| |
| if v and ',' not in v: |
| agent = v |
| return team_id, agent |
|
|
|
|
| def _group_dba_team(group): |
| """The team a top-level `or` group pins, or None. |
| |
| Recognises ONLY the exact shape "every child is a `dba eq <brand>` leaf" β the group the |
| condition builder emits for a multi-value BU condition, and the one `perm_migrate` writes. |
| Every OTHER group returns None and is left entirely to the post-filter: a group mixing `dba` |
| with another column, or containing a nested group, does not pin a BU on its own, and |
| guessing that it does would build the pool from the wrong book. Narrow by construction β |
| the pushdown may only ever be an OPTIMISATION of a constraint the filter already expresses. |
| """ |
| if group.get('conj') != 'or': |
| return None |
| children = group.get('children') or [] |
| if not children: |
| return None |
| vals = set() |
| for c in children: |
| if (not isinstance(c, dict) or isinstance(c.get('children'), list) |
| or c.get('colId') != 'dba' or c.get('op') != 'eq'): |
| return None |
| v = str(c.get('value') or '').strip().lower() |
| if not v: |
| return None |
| vals.add(v) |
| for allowed, tid in _DBA_TEAM: |
| if vals <= allowed: |
| return tid |
| return None |
|
|