diff --git "a/api/routes_admin.py" "b/api/routes_admin.py" --- "a/api/routes_admin.py" +++ "b/api/routes_admin.py" @@ -1,1809 +1,1829 @@ -"""routes_admin.py — Y4: user administration + the session's own settings (W2-3). - -These routes are the standalone shell's replacement for `app.py`'s `users_dialog` / `settings_dialog` -modals, over the SAME `core/users` account store — so both front-ends administer one set of -accounts and the eventual OIDC migration (D-3) swaps the CREDENTIAL check, not the user model. - -ADMIN-ONLY, FAIL-CLOSED. `admin_gate` is a role check (`perms.is_admin`), mirroring the Streamlit -dialog's `if not is_admin()`. A default record has `role: 'user'`, so an unreadable or partial record -is denied rather than admitted. `verify_api.py` proves it by having a viewer TRY every route. - -⛔ THIS IS THE FIRST WRITER OF `modules` THAT HAS EVER EXISTED. `core.users.set_access(modules=…)` -has no caller anywhere in the shipped app — module grants are only settable by editing the store -JSON out of band. That matters because the record format carries TWO documented fail-OPENS, both -asserted in `verify_api.py` section A: - - * `modules: []` is FALSY, so `perms.allowed_modules` reads it as 'all' = UNRESTRICTED. An admin - clearing every checkbox to lock an account down would grant it everything. - * `bus: []` falls through `allowed_bus_labels`'s "no recognisable label" branch to - `['All','Fisch','Royal']` — so one typo'd BU id is FULL cross-BU access, in the model whose - whole point is strict isolation. - -Y4 says do not "fix" `modules: []` in this wave, and that is right: the READER is mirrored in -`ui/session.py` and `core/perms.py` and diverging one of them mid-wave breaks lock-step. But the -WRITER is new, and it can simply refuse to create either footgun. So both are 400s here and both -read semantics are untouched — the seam is write-strict / read-unchanged. - -⚠ A STORE OUTAGE IS A 503, NEVER AN EMPTY LIST. `users.registry()` swallows a failed read into `{}` -one level down, and serving that as `{"users": []}` would tell an administrator their tenant has no -accounts. Same rule as the write path: an empty 200 is never how this API says "something is wrong". -""" -import os -import re - -from fastapi import APIRouter, Body, Depends, Response - -import core.platform_admin as platform_admin # wave 19 R3 — the /settings chrome flag -import core.registry as registry -import core.store as store - -from deps import Session, err, perms, require_session, users -from routes_auth import _public_user - -router = APIRouter(prefix="/api/v1") - -#: A username is a STORE KEY (`users.json`) and also the key the table workspace is filed under -#: (`data[username]`), so it is constrained rather than trusted: lowercase, no separators, no -#: whitespace, nothing that could traverse or collide once it becomes part of a path or a filename. -_UNAME_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{1,31}$") -#: ⭐⭐ WAVE 37 · T03 (owner ruling R8) — AN EMAIL ADDRESS IS ALSO A USERNAME. Owner: the username -#: IS the email, one identity. ⛔ THIS IS NOT A NEW CAPABILITY, IT IS THE DOOR CATCHING UP WITH THE -#: DATA: measured on deployed staging before the change, FIVE of tenant #0's eight accounts already -#: carry an `@` in the `username` field (`david@fischfloralsupply.com`, `farhan@`, `florencia@`, -#: `karen@`, `naomi@`) — every one of them illegal under the slug pattern above and longer than its -#: 32-character cap, so this route could not have created any of them. -#: ⚠ DELIBERATELY PERMISSIVE, because the store key is the thing being constrained and not the -#: deliverability of the address: one `@`, a dot-bearing domain, and the same character class the -#: slug already trusts. A stricter grammar here would refuse addresses that exist. -_UNAME_EMAIL_RE = re.compile(r"^[a-z0-9][a-z0-9._%+-]*@[a-z0-9][a-z0-9.-]*\.[a-z]{2,24}$") -#: RFC 5321's cap on a whole address. The slug form keeps its own 32 via `_UNAME_RE`. -_UNAME_MAX = 254 -#: Passwords are PBKDF2-200k, which is only as strong as what it is given. The Streamlit dialog -#: enforces nothing; this does, and the two are allowed to differ because only one of them is -#: reachable from the internet. -_MIN_PW = 8 -_ROLES = ("user", "admin") - - -def admin_gate(session: Session = Depends(require_session)) -> Session: - """401 without a session, 403 without the admin role. Role, not a module grant — mirroring - `app.py`'s `users_dialog`, whose only wall is `is_admin()`.""" - if not perms.is_admin(session.user): - raise err(403, "forbidden", "administrators only") - return session - - -def _require_store(): - """A 503 the moment the store cannot serve, so no route below can report emptiness as truth.""" - try: - ok = store.available() - except Exception: - ok = False - if not ok: - raise err(503, "store_unavailable", - "the tenant store is unavailable — accounts cannot be read or changed") - - -def _registry(): - _require_store() - try: - reg = users.registry() or {} - except Exception: - raise err(503, "store_unavailable", "the tenant store is unavailable") - return reg - - -def _view(uname, rec, governed=None, st=None, surface_keys=None): - """What an administrator may see about an account. NEVER `salt` or `hash` — this function is - the only projection these routes use, so there is one place that can leak and it does not. - - ⚠ `governed`/`st` ride through to `_access_summary` and nowhere else. The ROSTER passes both, - resolved once for the whole request; the single-record routes pass neither, because their - caller fetches the editor payload separately and `GET /perms` is the authority there. - """ - return {"username": uname, - "name": rec.get("name") or uname, - "role": rec.get("role", "user"), - "bus": rec.get("bus", "all"), - "bu_labels": perms.allowed_bu_labels(rec), - "modules": rec.get("modules", "all"), - "agent": rec.get("agent") or None, - "email": rec.get("email") or None, - "tenant": _tenant_of(rec), - "active": bool(rec.get("active", True)), - # The session-revocation handle. An admin needs it: it is the only visible evidence - # that "sign them out everywhere" actually happened. - "epoch": int(rec.get("epoch") or 0), - # S3 ask 4 — one sentence for the roster's Access column, so the list does not need - # a /perms round trip per row to fill one cell. Computed from the same record the - # editor will open, so the two cannot disagree. - "accessSummary": _access_summary(rec, uname=uname, governed=governed, st=st, - surface_keys=surface_keys), - "perms_v": int(rec.get("perms_v") or 0)} - - -def _access_summary(rec, uname="", governed=None, st=None, surface_keys=None): - """One sentence describing what this account may reach. Deliberately says LESS than the - editor: it is a signpost, not a rule listing, and a summary that tried to spell out filters - would be wrong the moment a filter got interesting. - - ⛔⛔ W36-T22 — IT MUST COUNT WHAT THE EDITOR WOULD SHOW, NOT WHAT IS STORED, AND THE TWO STOPPED - AGREEING THE MOMENT `get_perms` LEARNED TO DEFAULT FROM `may_read`. This counted STORED ENTRIES - only, which WAS the same answer before this wave: a migrated account had an entry for every - governable database, because the editor wrote one per topic on every save and no `ut_*` entry - could exist at all. Now a `ut_*` key legitimately has NO entry and is still OPEN, so the roster - would say *"1 module"* about an account the editor shows reaching twelve. ⛔ The roster is the - screen an administrator reads FIRST, and two screens disagreeing about one account is the - defect this wave exists to remove, not a rounding difference. - - ⚠ `governed`/`st` ARE PASSED IN RATHER THAN DERIVED HERE, and that is a cost decision. This - runs once PER ROW of the roster; asking `may_read` per (account x database) without a lend - would be a whole-document copy per question — D-213's shape, one route over. The caller - resolves the tenant's list and ONE lend for the whole request. A caller that passes neither - keeps the stored-entry reading, which is right for the single-record routes: their client - fetches `GET /perms` separately and that payload is the authority. - """ - import core.perm_scope as perm_scope - - if perms.is_admin(rec): - # amendment 4: an admin bypasses `perms` entirely, so rendering their stored rules as if - # they applied is the one misreading of that clause that could actually hurt. - return "Everything (admin)" - if not perm_scope.is_migrated(rec): - mods = perms.allowed_modules(rec) - return "All modules (legacy rules)" if mods is None else \ - f"{len(mods)} module{'' if len(mods) == 1 else 's'} (legacy rules)" - entries = (rec.get("perms") or {}) - if governed: - principal = users._public(uname, rec) if uname else rec - # ⛔ PER KIND, MIRRORING `get_perms`' OWN DEFAULTS — the first draft asked - # `nav_may_open` for EVERY key and that was a second idea of the count: for a migrated - # record with no topic entry the editor's default is `may_read` (DENY), so a blanket - # admission read would make the roster say "3 modules" about an account whose editor - # shows one box ticked. The roster's whole contract (leg 6 of the T22 section) is that - # it counts what the editor would show; a SURFACE key defaults from admission there, - # everything else from `may_read`, so this does exactly the same, key by key. - opened = [k for k in governed - if (perm_scope.nav_may_open(principal, k, st=st) - if k in (surface_keys or ()) else - perm_scope.may_read(principal, k, st=st))] - else: - opened = [k for k, e in entries.items() if isinstance(e, dict) and e.get("access", True)] - restricted = sum(1 for k in opened - if isinstance(entries.get(k), dict) - and (entries[k].get("filter") or entries[k].get("hiddenFields"))) - if not opened: - return "No modules" - base = f"{len(opened)} module{'' if len(opened) == 1 else 's'}" - return f"{base}, {restricted} restricted" if restricted else base - - -# ── request validation: every rule below is fail-closed ────────────────────────────────────────── -def _clean_username(v): - """The ONE validator for a new account's key. R8 widened it to admit a full email address. - - ⛔ IT HAS EXACTLY ONE CALL SITE AND THAT IS LOAD-BEARING, not an oversight to tidy up. - `PATCH /admin/users/{username}`, the password route and both `/perms` routes take the username - as a PATH parameter and never re-validate it, which is precisely why the five email-keyed - accounts above are administrable today despite being unconstructable. Arming this validator on - those routes would look like hardening and would break five live accounts. - """ - uname = str(v or "").strip().lower() - if len(uname) > _UNAME_MAX or not (_UNAME_RE.match(uname) or _UNAME_EMAIL_RE.match(uname)): - raise err(400, "bad_username", - "a username is either 2 to 32 characters of lowercase letters, digits, dot, " - "dash or underscore, or a full email address of up to 254 characters") - return uname - - -def _clean_password(v): - pw = str(v or "") - if len(pw) < _MIN_PW: - raise err(400, "weak_password", f"a password must be at least {_MIN_PW} characters") - return pw - - -def _clean_role(v): - role = str(v or "").strip().lower() - if role not in _ROLES: - raise err(400, "bad_role", f"role must be one of {list(_ROLES)}") - return role - - -def _clean_bus(v): - """'all', or a non-empty list of KNOWN business-unit ids. - - Refusing an unknown id is the whole point: `allowed_bus_labels` treats a list with no - recognisable label as full access, so `bus: [7]` would be a cross-BU grant created by a typo. - """ - if isinstance(v, str): - if v.strip().lower() == "all": - return "all" - raise err(400, "bad_bus", "bus must be 'all' or a list of business-unit ids") - if not isinstance(v, (list, tuple)) or not v: - raise err(400, "bad_bus", - "bus must be 'all' or a NON-EMPTY list of business-unit ids (an empty list " - "would read as unrestricted)") - out = [] - for b in v: - try: - b = int(b) - except (TypeError, ValueError): - raise err(400, "bad_bus", "a business-unit id must be a number") - if b not in users.BU_LABELS: - raise err(400, "bad_bus", - f"{b} is not a known business unit {sorted(users.BU_LABELS)} — an " - "unrecognised id would widen access to every BU") - out.append(b) - return sorted(set(out)) - - -def _clean_modules(v): - """'all', or a non-empty list of KNOWN module keys. - - `[]` is refused because it READS as unrestricted (see the module docstring). An unknown key is - refused because `may_open` is fail-closed on it: an admin who typed `sale` for `sales` would - silently lock the account out of the page they meant to grant. - """ - if isinstance(v, str): - if v.strip().lower() == "all": - return "all" - raise err(400, "bad_modules", "modules must be 'all' or a list of module keys") - if not isinstance(v, (list, tuple)) or not v: - raise err(400, "bad_modules", - "modules must be 'all' or a NON-EMPTY list of module keys — an empty list " - "reads as UNRESTRICTED, which is the opposite of locking an account down") - known = set(registry.BY_KEY) | set(perms._LEGACY_KEYS) - out, bad = [], [] - for k in v: - k = str(k or "").strip() - (out if k in known else bad).append(k) - if bad: - raise err(400, "bad_modules", f"unknown module keys: {sorted(bad)}") - return sorted(set(out)) - - -def _tenant_of(rec): - """The account's company, with the pre-wave default. ONE spelling of the default, used by - every admin route — two spellings is how a tenant wall grows a gap.""" - return str((rec or {}).get("tenant") or "royal-imports").strip().lower() - - -# ── the routes ─────────────────────────────────────────────────────────────────────────────────── -@router.get("/admin/users") -def list_users(session: Session = Depends(admin_gate)): - """Wave 18 (C1-TENANT): scoped to the CALLER'S tenant. The user registry is a global - control-plane bucket, so without this filter a Nurilab admin would read Royal's whole - roster — names, emails, agents — from one URL.""" - reg = _registry() - mine = _tenant_of(session.user) - # ⭐ W36-T22 — the tenant's governable list and ONE lend, resolved once for the whole roster so - # `_access_summary` can agree with the editor without paying a document copy per question. - import core.user_tables as _ut - try: - _rows = _perm_modules(session, surfaces=True) - governed = [m["key"] for m in _rows] - surface_keys = {m["key"] for m in _rows if m.get("surface")} - lent = _ut.lend_defs(session.runtime) - except Exception: # noqa: BLE001 - # A store that cannot list the tenant's databases must not take the roster down. The - # summary degrades to the stored-entry reading, which is what it always was. - governed, lent, surface_keys = None, None, None - return {"users": [_view(u, reg[u], governed=governed, st=lent, surface_keys=surface_keys) - for u in sorted(reg) - if _tenant_of(reg[u]) == mine]} - - -@router.post("/admin/users", status_code=201) -def create_user(body: dict = Body(default=None), session: Session = Depends(admin_gate)): - """Create an account. **409 if the username already exists — `PATCH` is the update path.** - - ⛔ WHY 409 AND NOT AN UPSERT. `core.users.create_user` writes a fresh `_record()`, and a fresh - record has NO `epoch` — so overwriting an existing account used to drop its epoch back to 0 and - RESURRECT every cookie minted before its last password change. `core/users.py` now - preserves-and-bumps on overwrite (which also closes it for `app.py`'s dialog, where "Add / - update a user" calls the same function), but this route still refuses: an upsert that silently - replaces an account's password, role and BU access because someone reused a username is not an - update anybody asked for. - """ - body = body or {} - _require_store() - uname = _clean_username(body.get("username")) - pw = _clean_password(body.get("password")) - role = _clean_role(body.get("role") or "user") - bus = _clean_bus(body.get("bus") if body.get("bus") is not None else "all") - modules = _clean_modules(body.get("modules") if body.get("modules") is not None else "all") - name = str(body.get("name") or "").strip() or uname - agent = str(body.get("agent") or "").strip() or None - email = str(body.get("email") or "").strip() or None - - _reg_before = _registry() - if uname in _reg_before: - raise err(409, "user_exists", f"{uname} already exists — PATCH it to change it") - # ⭐⭐ WAVE 37 · T03 (R8) — THE COLLISION THE WIDENING CREATES, REFUSED AT CREATE TIME. - # `core/users.py::verify` resolves a typed string by looking up the registry KEY first and only - # scanning the `email` field when that misses (its own wave-18 R1 comment). That precedence was - # incidental while a username could not contain `@`; now it decides which of two accounts a - # person reaches, so it is stated in both files rather than left to be re-derived. - # ⛔ BOTH DIRECTIONS, because each one silently takes a login away from an account that has it: - # (a) a new USERNAME equal to somebody's EMAIL wins the key lookup and shadows their address; - # (b) a new EMAIL equal to somebody's USERNAME can never be reached, because their key wins. - # Refused with a named error rather than allowed and explained afterwards: the failure is a - # person signing in and landing in the wrong account, which nothing downstream can detect. - if "@" in uname: - _shadowed = next((k for k, r in _reg_before.items() - if isinstance(r, dict) - and str(r.get("email") or "").strip().lower() == uname), None) - if _shadowed: - raise err(409, "username_shadows_email", - f"{uname} is already the email address on the account {_shadowed}, which " - f"signs in with it today. This account would take that login over. Choose a " - f"different username, or clear the email address on {_shadowed} first.") - if email: - _e = email.strip().lower() - if _e != uname and _e in _reg_before: - raise err(409, "email_shadows_username", - f"{_e} is already a username on this deployment, so anyone signing in with " - f"it reaches that account and never this one. Choose a different email " - f"address for this account.") - # Wave 18 (C1-TENANT): an admin creates accounts in their OWN company, never another's — - # the tenant is stamped from the session, not taken from the body. - users.create_user(uname, pw, name, role=role, bus=bus, modules=modules, - agent=agent, email=email, tenant=_tenant_of(session.user)) - reg = _registry() - if uname not in reg: - # The store accepted the write and does not have it. Reporting 201 here would be the - # "200 over a write that evaporated" failure the whole store-outage rule exists to prevent. - raise err(503, "store_unavailable", "the account was not saved — try again") - return {"user": _view(uname, reg[uname])} - - -@router.patch("/admin/users/{username}") -def update_user(username: str, body: dict = Body(default=None), - session: Session = Depends(admin_gate)): - """Change access on an existing account. Only the keys PRESENT in the body change. - - ⚠ NO EPOCH BUMP, and that is not an omission. `deps._user_for` re-reads the record on every - request, so a narrowed role / BU / module grant applies to the target's LIVE session on its very - next call — bumping the epoch would only add a forced re-login on top. `active: false` is the - exception and it bumps, inside `core.users.set_active`, because a disabled account must lose its - session rather than keep working until the cookie expires. - """ - body = body if isinstance(body, dict) else {} - reg = _registry() - uname = str(username or "").strip().lower() - if uname not in reg or _tenant_of(reg[uname]) != _tenant_of(session.user): - # Wave 18 (C1-TENANT): a cross-tenant username answers exactly like a missing one — - # 404, never 403, because "that account exists in another company" is itself a leak. - raise err(404, "no_such_user", f"no account named {uname!r}") - if not body: - raise err(400, "empty_patch", "no fields to update") - - unknown = sorted(set(body) - {"name", "role", "bus", "modules", "active", "agent", "email"}) - if unknown: - # A silently-ignored field is how a UI ends up believing it saved something it did not. - raise err(400, "unknown_fields", f"cannot update: {unknown}") - - is_self = uname == session.uname - role = _clean_role(body["role"]) if "role" in body else None - active = bool(body["active"]) if "active" in body else None - # THE ONE-CLICK LOCKOUT GUARD. APP_PASSWORD remains the real backstop for 'admin', so this is - # not the thing that keeps the product reachable — it just stops an administrator removing - # their own access with a toggle and having to go find the master password. - if is_self and role is not None and role != "admin": - raise err(400, "self_demote", - "you cannot remove your own administrator role — ask another admin") - if is_self and active is False: - raise err(400, "self_deactivate", "you cannot deactivate your own account") - - kw = {} - if role is not None: - kw["role"] = role - if "name" in body: - kw["name"] = str(body["name"] or "").strip() or uname - if "bus" in body: - kw["bus"] = _clean_bus(body["bus"]) - if "modules" in body: - kw["modules"] = _clean_modules(body["modules"]) - if "agent" in body: - kw["agent"] = str(body["agent"] or "").strip() # '' clears the link - if "email" in body: - kw["email"] = str(body["email"] or "").strip() - if kw: - users.set_access(uname, **kw) - if active is not None and active != bool(reg[uname].get("active", True)): - users.set_active(uname, active) # bumps the epoch: kills live sessions - - fresh = _registry() - return {"user": _view(uname, fresh[uname])} - - -@router.post("/admin/users/{username}/password", status_code=204) -def set_password(username: str, body: dict = Body(default=None), - session: Session = Depends(admin_gate)): - """Rotate a password. **Revokes every outstanding session for that account** — the epoch bump - happens inside the same read-modify-write as the hash (`core.users.set_password`), so the two - can never disagree. - - 204 with no body: there is nothing to say that the caller did not already know, and echoing the - account back would invite a client to diff a response for a password change. - """ - reg = _registry() - uname = str(username or "").strip().lower() - if uname not in reg or _tenant_of(reg[uname]) != _tenant_of(session.user): - # Wave 18 (C1-TENANT): a cross-tenant username answers exactly like a missing one — - # 404, never 403, because "that account exists in another company" is itself a leak. - raise err(404, "no_such_user", f"no account named {uname!r}") - pw = _clean_password((body or {}).get("password")) - before = int(reg[uname].get("epoch") or 0) - users.set_password(uname, pw) - after = int((_registry().get(uname) or {}).get("epoch") or 0) - if after <= before: - # The whole value of this route is the revocation. If the epoch did not move, the sessions - # were not revoked, and answering 204 would say they were. - raise err(503, "store_unavailable", - "the password change did not persist — outstanding sessions were NOT revoked") - return Response(status_code=204) - - -# ── C-PERM (wave 15): per-module access + permanent filters + hidden fields ────────────────── -#: The modules the permission editor governs — the GRID surfaces, the ones with a field schema -#: to filter and hide. Amendment 6 shipped the wall on `customer_data` alone and said this list -#: is the one place that changes when C-TOPIC lands. WAVE 16: it landed, so `product_data` -#: joins — and it is not cosmetic. `perms_v: 1` means an UNDECLARED module DENIES (amendment 4), -#: so a migrated account would be locked out of Product with no control anywhere to grant it: -#: fail-closed, but unadministrable. The editor grows its section with zero client edits -#: (S3 built it off this route's `modules` + `fields_by_module`). -#: -#: ⭐⭐ WAVE 33 (owner item 11, W33-T33) — **THIS IS A CATALOGUE, NOT AN ANSWER.** It used to be -#: both, and that was the defect the owner flagged four times: `get_perms` served this tuple -#: verbatim and never touched `session.runtime`, so EVERY tenant was handed tenant #0's two grid -#: modules. What this names now is a fact about THIS FILE — the registry topics `_module_fields` -#: below has a field-schema arm for — and the answer a tenant receives is `_perm_modules(session)`, -#: which filters this by the tenant's own provisioning and merges the tenant's own `ut_*` -#: databases. ⛔ Serving this list directly again re-opens item 11; `verify_perm_scope`'s -#: `section_tenant_derived` NC does exactly that and reds. -#: ⭐ CONTRACT C3, CONSUMED (lane E's `W33-T46`, answered as `E-3`). The topic list is DERIVED from -#: `registry.governable_modules()` — `{topic: {'key','label','subject'}}` over every non-archived, -#: non-group_only registry row that declares a `topic`. What stays hand-written is -#: `_FIELD_PROVIDER_KEYS` below, and that is CODE, not policy: each name has its own `aios_grid` -#: contract behind it in `_module_fields`, and a topic with no arm cannot be governed by this -#: editor at all. The intersection is the honest answer to "what can this module build pickers for". -#: -#: ⚠ KEPT AS A SYMBOL RATHER THAN DELETED, deliberately. `verify_api`'s W30a reads -#: `routes_admin._PERM_MODULES` to assert `perm_scope.BU_FILTERABLE_MODULES` names exactly the -#: governed topics whose contract carries `dba`. Deleting the name would make that gate raise -#: `AttributeError` — a CRASH, which scores as "the gate is broken" rather than as a red -#: [[gate-must-go-red-not-crash]] — and `verify_api.py` is lane A's fence. Derived-and-kept gives -#: that assertion a truer subject than the literal ever did, and costs nobody a cross-fence edit. -_FIELD_PROVIDER_KEYS = frozenset({"customer_data", "product_data"}) - - -def _governable_catalogue(): - """The REGISTRY topics this module can govern: `[{key, label}]`, in registry order. - - ⛔ No tenant filtering here — that is `perms.tenant_governable_modules`' job, and E's map is - tenant-blind by design. Two filters in two files answering one question is how the perms route - and `routes_nav` came apart in the first place. - """ - import core.registry as registry - return [{"key": v["key"], "label": v["label"]} - for v in registry.governable_modules().values() - if v.get("key") in _FIELD_PROVIDER_KEYS] - - -_PERM_MODULES = tuple(m["key"] for m in _governable_catalogue()) - - -def _perm_modules(session, surfaces=False): - """The databases THIS tenant's permission editor governs: `[{key, label, enforced}]`. - - Three inputs, none of them a literal: the topic catalogue above, the tenant's provisioned - module set (`tenant.config['modules']`, exactly the rule `routes_nav.py::nav` applies), and - the tenant's own `ut_*` databases read through the DEFINITIONS projection. - - ⭐ `user_tables.nav_entries` rather than a fresh listing, deliberately: it is already the ONE - resolver for "which databases may this account see", it is `may_open`-filtered, and it reads - the projected document (`all_defs`) so this costs definitions, never the 28.6 MB of rows. - The caller is admin-gated, so the filter admits the whole tenant — but asking the resolver - instead of assuming that is what keeps this from becoming the SECOND idea of who may see a - table (`user_tables.may_open`'s own docstring is the record of the first time that happened). - """ - import core.perms as _perms - import core.user_tables as user_tables - try: - ut = user_tables.nav_entries(viewer=session.uname, is_admin=True, - st=session.runtime) - except Exception: - # A store that cannot list the tenant's databases must not take the whole editor down — - # the topic half is still administrable, and an empty `ut_*` half is visibly empty. - ut = () - cat = _governable_catalogue() - skeys = set() - if surfaces: - # ⭐⭐ OWNER RULING 2026-08-18 — the Assistant and Agents surfaces are - # governable ACCESS toggles in the ACCOUNT editor. `surfaces` is caller-explicit and - # defaults CLOSED because the other consumer of this list is the CHANNEL-AGENT editor - # (`routes_slack.get_channel_agent`), whose principal is enforced per DATABASE - # (`agent_rows`/`agent_may_read`) and never opens an app surface: an Assistant toggle - # there would be a stored rule nothing applies, the exact class C2 deleted. - surf = _perms.governable_surface_modules() - skeys = {s["key"] for s in surf} - cat = cat + surf - rows = _perms.tenant_governable_modules(session.runtime, cat, ut_entries=ut) - # The marker survives OUTSIDE `tenant_governable_modules`, which builds bare {key,label} - # rows: the client needs it only to choose the schemaless help copy, and the server needs - # it only to pick the admission default in `get_perms`. - return [dict(r, surface=True) if r["key"] in skeys else r for r in rows] - - -#: ⛔ `_enforced_keys` IS DELETED (W36-T22 / CONTRACT C2). It asked `perms.enforced_module_keys` -#: which subset of the listed databases a wall could actually be stored against, and after R6 the -#: answer is "all of them" — a question with one possible answer is not a question. Both it and the -#: `perms` helper behind it are gone rather than made to return everything, because a predicate -#: that ignores its argument is the always-true flag C2 deletes, one indirection along. -#: What callers use instead is `{m["key"] for m in _perm_modules(session)}` — the SAME list the -#: editor renders, so "shown" and "storable" cannot drift apart again. - - -def _module_fields(key, session=None): - """The field schema the editor builds its pickers from — the SAME vocabulary the grid uses, - so an admin filters on exactly what the user sees. - - ⛔ Returned WITHOUT any per-user hiding applied, deliberately: this is the admin choosing - what to hide, so hiding the choices would make a field unhideable the moment it was hidden - once for somebody. The route is admin-gated; the projection a MEMBER receives is the one - `perm_scope.visible_fields` narrows. - - ⚠ PER TOPIC (wave 16). `product_data` has its OWN canonical contract, and handing the - editor the customer schema for it would let an admin build a wall out of columns that - module does not have — `_clean_perms` would then 400 on the admin's own choices, or worse, - a filter naming a field the product rows lack would DENY every row via `permits()`. - The product contract is served CONSOLIDATED (every column) on purpose: a BU-scoped reader - is served fewer columns, but the ADMIN is choosing what may ever be hidden. - - ⛔⛔ WAVE 33 — THE `else` USED TO BE A FALL-THROUGH, AND IT WAS SILENT-WRONG. The body read - `aios_grid.product_fields() if key == "product_data" else aios_grid.FIELDS`, so ANY key that - was not `product_data` got the CUSTOMER contract: the moment the governed list stopped being - a two-element literal, a `ut_*` database or a third topic would have been handed customer - columns, `_clean_perms` would have validated an admin's `hiddenFields` against them, and the - stored wall would name columns that module does not have — which `permits()` resolves by - DENYING every row. No error at any point. So the map is explicit and an unknown key RAISES: - a topic without a schema arm is a missing arm, never a customer grid in disguise. - - ⭐⭐ W36-T22 / R6 — AND `ut_*` NOW HAS AN ARM, WHICH IS THE HALF THAT MAKES THE TOGGLE REAL. - Owner item 11: *"how come the database is only toggleable for Odoo customers and Odoo - products … EVERY database should be able to be toggleable by admin."* A `ut_*` database's - field contract is its OWN stored definition, so the picker offers exactly the columns that - database has. ⛔ ONE SOURCE, NOT TWO: the picker (`get_perms.fields_by_module`) and the - validator (`_clean_perms`) both call this, so a column the picker offers can never be a - column the validator rejects. Getting that wrong is worse than an error message — - `permits()` DENIES on a predicate it cannot answer, so a mismatched wall is a - deny-everything trap wearing a saved-successfully toast. - - ⚠ `session` IS REQUIRED FOR A `ut_*` KEY and unused for a topic: a stored definition is - per-tenant data and there is no tenant-blind way to read one. A caller that omits it is - refused LOUDLY rather than handed another tenant's schema — the same rule the `else` - fall-through above was deleted for. - """ - import aios_grid - key = str(key or "") - if key in {s["key"] for s in perms.governable_surface_modules()}: - # ⭐ A SURFACE (Assistant, Agents) has NO field schema, and `[]` is the honest - # answer rather than the wave-26 empty-option trap: the editor's schemaless rendering - # is a deliberate state (access toggle only, R9), and `_clean_perms` refuses any - # filter or hiddenFields validated against this empty vocabulary. An unknown key - # still RAISES below — this arm admits exactly the derived surface list. - return [] - if key.startswith("ut_"): - if session is None: - raise err(400, "ungoverned_module", - f"{key!r} is a tenant database, so its columns can only be read for a " - f"known tenant — this caller passed no session") - import core.user_tables as user_tables - defn = user_tables.get(key, st=user_tables.lend_defs(session.runtime)) - if not defn: - raise err(400, "ungoverned_module", - f"{key!r} is not a database in this workspace") - src = defn.get("fields") or [] - else: - provider = _static_field_providers().get(key) - if provider is None: - # Reached only by a caller that skipped `_perm_modules`. Loud, because the alternative - # (`[]`) is an empty option list read as an ANSWER — wave 26 item 24, `if ([])` is - # truthy. - raise err(400, "ungoverned_module", - f"{key!r} has no permission-editor field schema. The editor governs " - f"{sorted(providers)} and this tenant's own databases") - src = list(provider()) - import core.perm_scope as perm_scope - # ⭐⭐ W40-T05 / OWNER I16 — THE USER-GENERATED COLUMNS JOIN THE TOPIC'S VOCABULARY. - # *"Permission Filters must be able to filter on user-generated Fields too."* The two topic - # providers above are the STATIC contract (`aios_grid.FIELDS` = 35, `product_fields()` = 40 - # including the built-in Image column), so until this line NO column a user created could - # ever reach the picker — measured, both topics, zero `custom_` keys in either. The `ut_*` - # arm never needed it: a user table's stored definition IS its user-generated schema. - # - # ⛔ ADDITIVE AND DEDUPED, so a declared column always wins its own key — the same rule the - # metric rows are appended under below. A user column shadowing a contract column would let - # an admin hide or filter something other than what they read off the label. - # - # ⚠ NO SESSION MEANS TODAY'S ANSWER, AND THAT IS CORRECT RATHER THAN DEGRADED. A workspace - # bucket is per-tenant data with no tenant-blind read, and two callers arrive without one: - # `_fold_legacy_scope` on the live PUT path and `verify_api`'s W30a probe. Both get the - # pre-set list they have always had, and the legacy fold cannot start dropping a leaf it used - # to keep, because `_prune_to_module` only ever narrows the LEGACY tree (`dba`/`agent`) to - # keys the contract already carries. - if key in _FIELD_PROVIDER_KEYS: - _have = {f.get("key") for f in src if isinstance(f, dict)} - for _uf in (perm_scope.user_generated_fields( - key, st=getattr(session, "runtime", None)) or ()): - if isinstance(_uf, dict) and _uf.get("key") and _uf["key"] not in _have: - _have.add(_uf["key"]) - src.append(_uf) - known = _server_side_vocabularies() - out = [] - for f in src: - if not isinstance(f, dict) or not f.get("key"): - continue - opts = f.get("options") - if not isinstance(opts, list) or not opts: - # ⭐ D-76 / W33-T35 — SUPPLY WHAT THE SERVER ALREADY KNOWS. See - # `_server_side_vocabularies`. - opts = known.get(f["key"]) - out.append({"key": f["key"], "label": f.get("label") or f["key"], - "type": f.get("type") or "text", - **({"options": list(opts)} if isinstance(opts, list) and opts else {}), - **({"pinned": True} if f.get("pinned") else {}), - # ⭐⭐ CONTRACT C3 / AM-2 — THE FOUR MEMBERSHIP BOOLEANS, ON EVERY ROW. - # READ OFF THE FIELD'S OWN DECLARATION, never inferred from its key, because - # the declaration is the authority the GRID already uses: - # `aios_grid.clean_measure_field` writes `custom: True` on a user-created - # column, the product contract declares `shared: true` on its nine - # tenant-wide-value columns, and `filterable: False` is how a column states - # the engine cannot filter it (`est_missed` is the one that does). - # - # ⚠ `shared` IS INFORMATIONAL AND MUST NEVER BECOME THE HIDE RULE. AM-2's - # rule is `metric || !custom`. Nine declared PRODUCT columns carry - # `shared: true` (`first_cost`, `supplier`, `notes`, ...) and every one of - # them has to stay hideable — `verify_api`'s legacy-fold leg hides - # `first_cost` by name. A client that excluded rows on `shared` would revoke - # the admin's control over nine pre-set columns, which is I15's own defect - # one word over. - "custom": bool(f.get("custom")), - "shared": (bool(f.get("shared")) - or f.get(perm_scope.FIELD_GRANT_MARK) is True), - "filterable": f.get("filterable") is not False, - "metric": False}) - # ⭐⭐ I13 — THE METRIC ROWS, APPENDED LAST so no declared column is ever displaced, and - # skipped on a key collision so a declared column always wins its own key. - taken = {f["key"] for f in out} - out.extend(m for m in _metric_fields(key) if m["key"] not in taken) - return out - - -def _metric_fields(key): - """One `measure_`-namespaced pseudo-field per measure BOUND to `key`'s entity topic. - - ⭐⭐ I13 — THE ROW THE OWNER ASKED FOR: *"Fold 'Metrics fields (lookback measures over - this database)' into the 'Hide fields' checkboxes, one row per metric, named - 'Metric - Revenue', 'Metric - Order' and so on, so a user can check the ones the - permissioning is limited to."* Until this existed the entire capability was ONE boolean - (`perm_scope.may_metrics`), so an administrator could revoke every metric on a database or - none of them. - - ⭐⭐ AND IT IS WHY I15 HAD NO CONTROL TO OFFER. *"Shantal has access to gross profit."* - "Gross profit" is the measure `margin` — label "Gross margin $", defined at - `platform/model/metrics/sales.yml::margin` and bound onto this grid by - `platform/model/topics/odoo_products.yml::measures`. **It is not a column in - `aios_grid_fields.json` and never was**, so a picker built from the declared contract alone - could not list it however carefully that contract was read. No key literally named - `gross_profit` reaches this grid at all. - - ⛔ THE OFFER IS READ, NEVER RESTATED. `semantic.entity_measures` is the ONE catalogue, and - it REFUSES a key it cannot prove — a half-synced mirror, a metric with no `empty:` family, - a format with no grid type — with the cause available through `entity_measure_refusals`. - Re-measured 2026-08-24 in this tree, after W40-T10 bound the channel split: the product topic - declares **27 key-slots across five bindings** and the catalogue offers **24**; - `stock_in`/`stock_out`/`stock_net` are refused because `stock_move` has not finished syncing. - Hardcoding a declared list would offer three columns that render blank today AND would stop - growing the day a binding is added. The offer widens on its own when the mirror catches up, - because this asks rather than remembers — and the eighteen channel rows below - ("Metric - Revenue (Fisch)", "Metric - Revenue (Amazon)", …) arrived here with NO change to - this function for exactly that reason. - - ⭐⭐ W40-T10 / owner item 26 (R7) — AND THAT IS WHY THE CHANNEL RIDES THE MEASURE KEY. A - per-metric channel spelled as a member of the stored field spec would be invisible here: - this row is minted from `m["key"]` and `m["label"]`, so a channel carried anywhere else - would give an admin eighteen columns they can neither tell apart nor hide separately. - `semantic._one_binding_offer` therefore namespaces the key and appends "(Fisch)" to the - label, and both halves land in this vocabulary for free. - - ⛔ THE TOPIC IS DERIVED, NEVER MAPPED. `semantic.topic_for_grid` reads the `grid:` key the - topic file already declares, which is the reason its own docstring gives for existing: a - `{module: topic}` literal in a route would be a second statement of one fact and would - drift the day a topic is renamed. It answers for every arm — `product_data` -> - `odoo_products`, `customer_data` -> `odoo_customers` (which binds none), `ut_odoo_agents` -> - `odoo_agents` (which binds seven) — so this needs NO per-module branch, and a database - with no bound measure simply receives no rows. - - ⛔⛔ IT CANNOT RAISE, AND THAT IS LOAD-BEARING RATHER THAN DEFENSIVE. Two callers reach - `_module_fields` with NO session and outside any store fixture: `_fold_legacy_scope`, on the - live migration path of a PUT, and `verify_api`'s W30a probe, which runs ABOVE the line that - installs the fake store. A raise on either is not a red gate but a CRASHED one - [[gate-must-go-red-not-crash]], and on the route it is a 500 on the administrator's own save. - `entity_measures` already degrades to `[]` when the mirror cannot be read; this makes the - whole derivation degrade with it, so the payload simply carries no metric rows. - - ⛔ `filterable: False`, AND IT IS A MEASUREMENT RATHER THAN A PREFERENCE. A pseudo-key is - not a column any row carries — a real Metric column is keyed `measure__` by - `CustomerGrid.createField`. Measured: `clean_filter_tree` ACCEPTS a leaf on `measure_margin` - once the key is admitted, and `harness.filter_eval.permits` then answers **False for every - product row**, because the row has no such key. That is exactly the "deny-everything trap - wearing a saved-successfully toast" `_module_fields`' own docstring forbids. The governed way - to filter on a measure is the measure CONDITION channel (`measure_sets`, CG-8), not a field - leaf, so these rows join the HIDE vocabulary only. - - ⛔ THE PREFIX IS `aios_grid.MEASURE_FIELD_PREFIX`, IMPORTED. Spelling "measure_" here would - be a second copy of a constant two features already share - [[constant-two-features-share]], and `verify_fields_contract` gates that the client keys its - real measure columns off the same one. - """ - try: - import aios_grid - import harness.semantic as semantic - topic = semantic.topic_for_grid(key) - if not topic: - return [] - offer = semantic.entity_measures(topic) - except Exception: # noqa: BLE001 - return [] - out = [] - for m in offer or (): - if not isinstance(m, dict): - continue - mkey = str(m.get("key") or "").strip() - if not mkey: - continue - # ⭐ THE LABEL IS THE MEASURE'S OWN, prefixed. A hand-written second vocabulary here - # would read "Metric - Gross profit" while the grid column read "Gross margin $", and an - # admin cannot hide what they cannot recognise. ASCII hyphen, exactly as the owner wrote - # it — CLAUDE.md rule 2 bars a dash in copy that reaches a screen. - label = str(m.get("label") or mkey).strip() - mtype = m.get("type") - out.append({"key": f"{aios_grid.MEASURE_FIELD_PREFIX}{mkey}", - "label": f"Metric - {label}", - "type": mtype if mtype in aios_grid.MEASURE_FIELD_TYPES else "currency", - "custom": False, - "shared": False, - "filterable": False, - "metric": True}) - return out - - -#: The value every blank Odoo connector attribute displays as. The writer is -#: `modules/customers._partner_attrs` (`... or '(none)'`) and the client mirror is -#: `customer-grid/types.ts::CONNECTOR_BLANK`. It is a DELIBERATE, documented, visible value, so a -#: choice vocabulary that omitted it would deny an admin any rule about the blank rows. -BLANK = "(none)" - -#: `_server_side_vocabularies`' per-process memo. `None` until a COMPLETE build succeeds; a build -#: with any failed arm is returned but NOT stored, so a transient Odoo outage cannot pin a short -#: answer for the life of the worker. -_VOCAB_CACHE = None - - -def _server_side_vocabularies(): - """`{field key: [choice, …]}` for choice columns whose vocabulary is CLOSED and known here, - but which the grid contract does not declare. - - ⛔ D-76 — WHY THE PERMISSION EDITOR'S DROPDOWNS WERE EMPTY, AND WHY THE FIX IS HERE. The grid - resolves a choice column through `types.ts::choiceVocabulary`: the DECLARED list when the - field has one, otherwise the values seen in the loaded ROWS. This editor loads no rows, so - every column that leans on the second branch renders `Select…` with nothing in it — the - identical shape wave 26 item 24 closed on the Product grid, one surface over. ⚠ And the fix - belongs at the CALLER, never in the panel: `FilterBuilderPanel::choicesFor` treats a supplied - list as authoritative *including when it is empty* ("this column has no values" is an answer), - which is correct and must not be weakened. So the caller stops handing it nothing. - - ⭐ IMPORTED, NEVER RESTATED. `stock_bucket`'s labels are `modules/inventory.COVERAGE_LABELS`, - the same constant `_bucket()` mints from — a copy here would be a second vocabulary that - drifts the first time a band is renamed [[constant-two-features-share]]. The import is lazy - for the same reason `aios_grid`'s is: this route should not pull the analytics stack at - module load. - - ⭐⭐ W40-T06 / OWNER I11+I23 — NINE MORE CHOICE COLUMNS, AND WHY THE OLD REFUSAL EXPIRED. - Ruling R5 retyped ten Odoo customer columns off `text`, so nine of them now reach the branch - above and D-236 (`category` renders an EMPTY dropdown here) would have been multiplied by - nine rather than fixed. `category` was refused in this docstring because "discovering it - would mean a pool read on an admin route". That reason was WRONG about the cost, not about - the principle: none of the reads below is a pool read. Each is either a `read_group` over - `res.partner` (one grouped row per distinct value) or a whole small master table, and the - dearest of them was MEASURED at 1.8s cold, once per process. - - ⛔⛔ A VOCABULARY MUST MATCH THE CELL, NOT THE RAW SOURCE, and the pricelist is the proof. - `modules/customers._partner_attrs` writes `O.m2o_name(property_product_pricelist)`, which is - Odoo's DISPLAY name: the cell reads "Fisch (USD)". `product.pricelist.name` reads "Fisch". - A vocabulary built from `name` would therefore offer five options, and every one of them - would match ZERO rows. So every arm below reproduces the writer's own expression: - `display_name` for an m2o, `.strip().title()` for `city`, the `', '.join(...)` for the m2m - `agent`, and `'(none)'` wherever `_partner_attrs` collapses a blank (the set of fields that - do is documented in the contract JSON's own `_comment`). - MEASURED against real cells over all 5,282 partners, 2026-08-23: country 20 offered / 20 - observed, state 64 / 64, agent 14 / 13, city 1,254 / 1,245, pricelist 5 / 4, - payment_terms 15 / 10, tags 6 / 5. ⚠ Read the direction: NOT ONE observed cell value is - missing from what is offered. The overshoot is partners outside the grid's own 24 month - population, and it is the SAFE direction. A missing option is the dangerous one, because it - silently denies the admin any rule about those rows. - - ⚠ `tags` IS A MULTISELECT, so it is offered as MEMBERS, not as joined cells. - `choiceVocabulary` splits a multiselect cell on commas, so "Fisch, Royal" must arrive as - "Fisch" and "Royal". `agent` stays a SINGLE select per R5 and therefore keeps its joined - combinations. ⛔ NOTE FOR THE OWNER, not acted on here: `agent` is comma joined multi value - exactly as `tags` is (13 observed cells are combinations such as "Avi Ash, Martin - Pasternak"), so R5's own argument for making Tags a multiselect applies to it too. R5 rules - single select, so single select is what this builds. - - ⛔ DEGRADE BY OMITTING THE KEY, NEVER BY RETURNING `[]`. `FilterBuilderPanel::choicesFor` - treats a supplied list as authoritative INCLUDING when it is empty, so an empty list asserts - "this column has no values". Every arm is therefore isolated in its own try/except and a - failed read leaves its key OUT, exactly as the original single-arm version did. - - ⚠ CACHED PER PROCESS, because `_module_fields` runs on every admin GET and on every PUT via - `_fold_legacy_scope`. Only SUCCESSES are cached, so a transient Odoo outage cannot pin an - empty answer for the life of the worker. STALE WHEN: someone adds or renames a pricelist, - payment term, partner tag or product category; a customer moves city, state or country; an - agent is assigned or unassigned; a new salesperson takes their first order. None of those is - observable from here, and the refresh is a process restart, which a deploy already does. - - ⚠ ONE COLUMN IS STILL DELIBERATELY ABSENT, and R6's second sentence says to name it rather - than let it look handled: - · `odoo_status` (customer) — WAS bare and is NOT any more: lane E answered `ASK D-15` by - declaring `options: ['Active','Archived']` on the CONTRACT, which is the right home (the - grid picks a declared list up for free) and is why this function never needed an arm for - it. Recorded because the ask, not a guess, is what settled it. - """ - global _VOCAB_CACHE - if _VOCAB_CACHE is not None: - return dict(_VOCAB_CACHE) - - out = {} - - def arm(fn): - """Run one source. A failure omits its keys and never touches the others.""" - try: - for key, values in (fn() or {}).items(): - clean = sorted({str(v) for v in values if str(v or "").strip()}) - if clean: - out[key] = clean - return True - except Exception: # noqa: BLE001 - return False - - def _stock_bucket(): - # IMPORTED, NEVER RESTATED: the same constant `_bucket()` mints from, so a renamed band - # cannot leave a second vocabulary behind [[constant-two-features-share]]. Lazy for the - # same reason `aios_grid`'s import is: no analytics stack at module load. - import modules.inventory as inventory - return {"stock_bucket": list(inventory.COVERAGE_LABELS or ())} - - def _categories(): - import modules.inventory as inventory - # W40-T06: the root maps to None now, so "All" cannot reach either list. The sentinel is - # what `products.catalogue` and `sales._product_cat` substitute for that None. - cats = sorted({v for v in inventory._cat_main_map().values() if v}) - return {"category": cats + ["(uncategorized)"], - "top_category": cats + ["(uncategorized)", BLANK]} - - def _suppliers(): - """The product `supplier` column's observed values. - - ⛔⛔ THIS ARM EXISTS BECAUSE W40-T06 CREATED THE HOLE IT FILLS. `supplier` was `text` - until I18 made it a multiselect, and a choice column with no declared options and no - entry here is precisely D-236's empty dropdown. The flip would otherwise have ADDED a - tenth blank picker while this ticket was removing nine. - - ⛔⛔ DECISION OWED, AND IT IS VISIBLE ON SCREEN: 18 of the 88 distinct suppliers carry a - COMMA INSIDE THE COMPANY NAME ("CANDLE ARTISANS, INC", "BAOBEI INT'L CO.,LTD"), spanning - 371 product codes. A multiselect cell is by contract a comma-joined SET, and - `types.ts::choiceVocabulary` SPLITS it, so the grid's own row-derived picker will offer - 26 fragments ("CANDLE ARTISANS", "INC") where 18 real suppliers should be. That is a - data fact about supplier names, not a bug in the splitter: unlike `tags`, `supplier` is - a SINGLE value that merely contains punctuation. This function serves the WHOLE values, - which is what the cells actually hold and what an admin means to filter on. The kind - itself is the owner's to settle (`select` would make the collision disappear); I18 says - multi-select, so multi-select is what the contract carries. - - ⚠ SEED VALUES, NOT LIVE CELLS. `supplier` is an OVERLAY column a user may retype per - record, and those edits live in the tenant's workspace, which this route cannot read - tenant-blind. So a supplier somebody typed by hand is missing from this list until the - mastersheet carries it. Cheap and safe: `supplier_master()` is a local file cached per - process, and it degrades to `{}` rather than raising. - """ - import modules.product_data as product_data - return {"supplier": [(v.get("supplier") or "").strip() - for v in (product_data.supplier_master() or {}).values()]} - - def _masters(): - import core.odoo as O - got = {} - for key, model in (("pricelist", "product.pricelist"), - ("payment_terms", "account.payment.term"), - ("tags", "res.partner.category")): - # display_name, never name: see the pricelist proof in the docstring. - got[key] = [c["display_name"] for c in O.search_read(model, [], ["display_name"])] - if key != "pricelist": # measured: every customer carries a pricelist - got[key] = got[key] + [BLANK] - return got - - def _partner_dims(): - import core.odoo as O - rg = lambda field: O.read_group("res.partner", [], ["id"], [field], lazy=False) - return { - "country": [O.m2o_name(r.get("country_id")) or BLANK for r in rg("country_id")], - "state": [O.m2o_name(r.get("state_id")) or BLANK for r in rg("state_id")], - # `.strip().title()` is `_partner_attrs`' own expression. Raw mirror values would - # offer "NEW YORK" where the cell says "New York": no match, in both directions. - "city": [(r.get("city") or "").strip().title() or BLANK for r in rg("city")], - } - - def _agents(): - import core.odoo as O - rows = O.search_read("res.partner", [], ["agent_ids"]) - ids = {i for r in rows for i in (r.get("agent_ids") or [])} - names = {p["id"]: p["name"] - for p in O.search_read("res.partner", [("id", "in", list(ids))], ["name"])} - return {"agent": [", ".join(n for n in (names.get(i) for i in (r.get("agent_ids") or [])) - if n) or BLANK for r in rows]} - - def _salespeople(): - # The ORDER TAKER over LTM (`sale.order.user_id`), which is what the column holds, NOT - # `res.users`: `modules/customer_data._salesperson_attrs` is the writer, and it reads the - # order's taker. Grouped, so the result is one row per distinct person. - import core.odoo as O - import core.periods as P - import modules.sales as S - mf, mt = P.ltm(P.today()) - rows = O.read_group("sale.order", S.order_domain(str(mf), str(mt), None), - ["amount_untaxed:sum"], ["user_id"], lazy=False) - return {"salesperson": [O.m2o_name(r.get("user_id")) or BLANK for r in rows]} - - complete = all([arm(source) for source in - (_stock_bucket, _categories, _suppliers, _masters, _partner_dims, - _agents, _salespeople)]) - if complete: - _VOCAB_CACHE = dict(out) - return out - - -def _prunable_vocabulary(key, session=None, fields=None): - """The field keys `key` HAS right now — or `None` when that is not trustworthy enough to - prune a stored permission filter against. - - ⛔⛔ THE RETURN IS A SAFETY DEVICE, NOT A CONVENIENCE. The cascade below DELETES a filter leaf - naming a key that is not in this set, so a set that is short by one column revokes a live - permission rule — permanently, silently, and in the WIDENING direction. Three separate ways - to be short are refused here rather than papered over: - - * a TOPIC whose workspace bucket could not be read (`user_generated_fields` answers `None`, - which is exactly why it distinguishes that from `[]`). This is the case that would fire - under ordinary store contention, and it is the dangerous one: measured in this tree, a - busy DuckDB already takes `_module_fields('product_data')` from 45 rows to 39. - * a SURFACE (Assistant, Agents), whose vocabulary is `[]` by design. Pruning against an - empty set would drop every leaf of anything stored there. - * any key whose schema read RAISES — an unknown module, a `ut_*` database whose definition - the store cannot serve. - - ⚠ `fields` is threaded so the ONE `_module_fields` call a caller has already paid for is - reused. Recomputing it would double a read that costs a store round-trip per module on a - route that already makes one per governed database. - """ - import core.perm_scope as perm_scope - - key = str(key or "") - if key in _FIELD_PROVIDER_KEYS: - if perm_scope.user_generated_fields(key, st=getattr(session, "runtime", None)) is None: - return None - elif not key.startswith("ut_"): - return None - if fields is None: - try: - fields = _module_fields(key, session=session) - except Exception: # noqa: BLE001 - return None - return {f["key"] for f in fields if isinstance(f, dict) and f.get("key")} or None - - -def _prune_stale_filters(block, session=None): - """`(block, {module: [dropped keys]})` — owner I16's cascade over a whole stored perms block. - - ⭐⭐ *"If the field is deleted, its permission filter goes with it."* Run at the ADMIN DOOR, - on the record, never in the wall: `perm_scope.prune_filter_to_fields`' own docstring carries - the reason (`verify_perm_scope`'s "a wall naming a DELETED column denies every row" leg must - stay green, and it asserts on `apply_row_scope` directly). - - ⚠ NON-DESTRUCTIVE and entry-wise: an entry with no filter, an entry whose vocabulary will not - resolve, and an entry with nothing stale all come back as the object that went in. Only an - entry that actually lost a leaf is rebuilt, so a caller can use the returned map as its - "is a write needed" test rather than comparing documents. - """ - import core.perm_scope as perm_scope - - out, dropped = dict(block or {}), {} - for key, entry in (block or {}).items(): - if not isinstance(entry, dict) or not entry.get("filter"): - continue - vocab = _prunable_vocabulary(key, session=session) - if vocab is None: - continue - tree, gone = perm_scope.prune_filter_to_fields(entry["filter"], vocab) - if gone: - out[key] = dict(entry, filter=tree) - dropped[key] = gone - return out, dropped - - -def _static_field_providers(): - """The STATIC contract behind each topic grid, read by TWO callers. - - `_module_fields` builds the picker from it and then APPENDS the user-generated columns; - `_row_wall_blind_keys` needs the same set to tell those two apart. A second copy of this map - would let them disagree about which columns are the database's own, and the disagreement - would show up as a permission filter refused on a column that was never user-generated. - """ - import aios_grid - - return {"customer_data": lambda: aios_grid.FIELDS, - "product_data": aios_grid.product_fields} - - -def _leaf_col_ids(nodes): - """Every `colId` a filter tree names, at any depth. Groups carry `children`; leaves do not.""" - found = set() - for node in nodes or (): - if not isinstance(node, dict): - continue - if isinstance(node.get("children"), list): - found |= _leaf_col_ids(node["children"]) - elif node.get("colId"): - found.add(str(node["colId"])) - return found - - -def _row_wall_blind_keys(key, session=None): - """User-generated columns of a TOPIC grid, which the row wall structurally cannot answer. - - THE DEFECT THIS REFUSES. W40-T05 taught `_module_fields` to append - `perm_scope.user_generated_fields`, so the permission editor now OFFERS a column a user made - (owner instruction 16), and this validator accepted a wall naming it. But on the two topic - grids the wall runs at `routes_customers.py:277` / `:495` and `routes_products.py:118` as - `apply_row_scope(rows, user, MODULE, )`, over PRE-OVERLAY rows: the - column is neither declared in that field list nor present on the row, and - `filter_eval.permits` DENIES every leaf it cannot answer. - - Measured on the integrated head, one leaf {colId: fld_region, op: eq, value: West}, two rows: - - static contract + pre-overlay rows (THE LIVE CALL SITE) -> [] every row denied - column declared + rows carrying the value -> ['1'] correct - column declared + pre-overlay rows -> [] - a DECLARED odoo column, same shape -> ['1'] the evaluator is fine - - So an administrator saves a rule, is told it saved, and that account opens an empty grid with - nothing on screen saying why. Fail-CLOSED, and one click undoes it, but silent. - - REFUSED AT THE WRITE, WHERE A PERSON IS PRESENT TO FIX IT, which is the same call - `_refuse_unshapeable_bu` makes further down this file for the same reason. The alternative was - to move the wall after the overlay merge, and that is not a small change: `apply_row_scope` - runs before `pids` is taken precisely so a row this account may not see never enters the - workspace, the cohorts or the measures. Reshaping that at QA time to fix a silent-deny would - trade a visible defect for an invisible one. - - NARROW BY CONSTRUCTION, three ways. Only the two TOPIC grids: a `ut_*` database enforces - correctly today, because its stored definition IS its user-generated schema and its rows carry - the values. Only the FILTER: `hiddenFields` still accepts these columns, because hiding is - applied to the assembled field list, which does have them. And only columns the static - contract does NOT declare, so a user column shadowing a real one cannot make the real one - unfilterable. - - `user_generated_fields` answers `None` when the vocabulary cannot be read, and `None` is - handled by NOT narrowing, which is correct rather than lax: on that same answer - `_module_fields` appended nothing, so there is nothing in `filter_keys` to take out. - - THE PROPER FIX IS BOOKED, NOT DONE: teach the three doors to wall AFTER the overlay merge with - a field list that declares the column. Until then instruction 16 is delivered for `ut_*` - databases and REPORTED as unavailable on the two Odoo topics, which is what CLAUDE.md rule 1 - demands of a limit that cannot yet be removed. - """ - if key not in _FIELD_PROVIDER_KEYS: - return set() - import core.perm_scope as perm_scope - - provider = _static_field_providers().get(key) - declared = {f.get("key") for f in (provider() if provider else ()) - if isinstance(f, dict)} - found = perm_scope.user_generated_fields(key, st=getattr(session, "runtime", None)) - return {f["key"] for f in (found or ()) - if isinstance(f, dict) and f.get("key") and f["key"] not in declared} - - -def _clean_perms(v, governed_keys=None, session=None): - """Validate a whole `perms` block. FAIL-CLOSED, and LOUD rather than lenient. - - ⭐ WAVE 33 (W33-T33) — `governed_keys` IS PER TENANT AND IT IS NOT OPTIONAL IN PRACTICE. It - used to be `set(_PERM_MODULES)`, a literal, which is the same tenant-blindness owner item 11 - flagged: an admin of a tenant provisioned for neither grid module could still store a wall - for both. It now comes from `_perm_modules(session)`, i.e. from the tenant's own catalogue. - The default is the topic catalogue itself so a test or a future caller cannot silently widen - it. - - ⭐⭐ W36-T22 / CONTRACT C2 — **THE `enforced`/`listed` SPLIT IS GONE, AND SO IS - `unenforced_module`.** This function took TWO key sets and refused anything in the gap - between them, with a message that ended *"Share the database instead, or arm `perm_scope` - over `ut_*` first."* W36-T21 armed it. There is no gap left: every database the editor lists - is a database whose wall `routes_tables` applies on every read, so a wall stored against one - is enforced by the same code that enforces `customer_data`'s. **Two sets collapse into one**, - which is the shape C2 asks for — not a second set that is always equal to the first. - - ⚠ `session` IS THREADED SO `_module_fields` CAN READ A `ut_*` SCHEMA. Without it the - validator would have no field contract for the databases this ticket just made governable, - and "no contract" resolves to "every `hiddenFields` entry is unknown" — a 400 on the admin's - own screen. - - ⛔ WHY THIS REFUSES WHERE `clean_filter_tree` DROPS. The view sanitiser is deliberately - drop-per-node: one bad rule must never cost a user their whole saved view. A PERMISSION - filter is the opposite situation — an admin types "agent is Tara", a leaf is silently - dropped, and the stored wall is EMPTY. The admin sees "Saved", the account sees the whole - book, and nothing anywhere says so. So anything that would be dropped is a 400 instead: - the filter is re-validated with `clean_filter_tree` and the result must come back with the - SAME leaf count it went in with. - - Shape (C-PERM amendment 2 — a `FilterTree` is a PAIR, `{conj?, nodes}`; `clean_filter_tree` - validates the NODE LIST alone and never sees the root conjunction, so the two are checked - separately and a bare node list is refused rather than silently read as `and`). - """ - import aios_grid - - if v is None: - return None - if not isinstance(v, dict): - raise err(400, "bad_perms", "perms must be an object keyed by module") - governed = set(governed_keys) if governed_keys is not None else set(_PERM_MODULES) - unknown = sorted(set(v) - governed) - if unknown: - raise err(400, "bad_perms", - f"unknown module keys: {unknown} — this tenant's permission editor governs " - f"{sorted(governed)}") - out = {} - for key, raw in v.items(): - if not isinstance(raw, dict): - raise err(400, "bad_perms", f"{key}: each entry must be an object") - # ⭐⭐ AM-2 — ONE SOURCE, TWO VOCABULARIES, AND THE VALIDATOR READS BOTH. The - # picker and this validator still call the SAME function, which is the property - # `_module_fields`' docstring exists to protect; what changed is that a field now states - # which vocabulary it belongs to, and each consumer reads its own membership instead of - # the whole list. - # - # ⛔ THE SPLIT IS WHY THE METRIC ROWS ARE SAFE TO ADD. `hiddenFields` is validated - # against EVERY key (a metric row is hideable — that is I13's whole point), while the - # permanent FILTER is validated against `filterable` only. Without the second set, - # admitting `measure_margin` would make {"colId": "measure_margin", ...} a storable - # permanent wall, and `permits()` answers False for every row that has no such key: - # measured, not feared. `clean_filter_tree` takes bare strings and cannot see the flag, - # so the narrowing has to happen HERE, at the call. - fields_here = _module_fields(key, session=session) - valid_keys = {f["key"] for f in fields_here} - filter_keys = {f["key"] for f in fields_here if f.get("filterable")} - # W40-T05 x W40-T18, see `_row_wall_blind_keys`. A user-generated column of a TOPIC grid - # is HIDEABLE (it stays in `valid_keys`) and NOT FILTERABLE, because the row wall on those - # two doors runs over pre-overlay rows and denies every leaf it cannot answer. - row_wall_blind = _row_wall_blind_keys(key, session) - filter_keys -= row_wall_blind - hidden = raw.get("hiddenFields") or [] - if not isinstance(hidden, list): - raise err(400, "bad_perms", f"{key}: hiddenFields must be a list") - submitted = {str(h) for h in hidden} - # ⛔⛔ A METRIC TICK IS ADMITTED ON ITS PREFIX, NOT ON THE LIVE OFFER, AND THAT CLOSES A - # GET/PUT SKEW RATHER THAN WEAKENING THE GUARD. - # - # `_metric_fields` asks `semantic.entity_measures`, which REFUSES every key while the - # mirror cannot be read — measured twice in this tree on 2026-08-23, when another - # process held the DuckDB and the offer came back empty. That answer is deliberately not - # cached (`_ENTITY_OFFER_CACHE` skips an indeterminate one), so the state recurs freely. - # Without this line the sequence is: the editor GETs a payload carrying - # `measure_margin`, the admin ticks "Metric - Gross margin $", the PUT lands a moment - # later while the store is busy, `valid_keys` has 39 entries instead of 45, and the - # administrator is told their own tick *"names unknown fields"*. That is this function's - # ONE SOURCE invariant running backwards — the validator rejecting exactly what the - # picker offered. - # - # ⭐ AND ADMITTING IT IS CORRECT, not merely convenient, because the ENFORCEMENT keys off - # the measure NAME and not off the offer: `perm_scope._measure_bound_keys` hides every - # column whose `measure.key` matches, whether or not the catalogue is answering right now. - # So a tick stored during a warm-up walls the right columns the moment they render. A - # metric key that never comes back hides nothing, which is the harmless direction; the - # refusal is the harmful one. - # - # ⚠ NARROW BY CONSTRUCTION: only the `measure_` namespace is admitted this way, and only - # with a non-empty suffix. Every other unknown key is still refused, because a - # `hiddenFields` entry naming nothing reads as a restriction that is not there. - _mpre = aios_grid.MEASURE_FIELD_PREFIX - metric_ticks = {h for h in submitted - if h.startswith(_mpre) and len(h) > len(_mpre)} - bad = sorted(submitted - valid_keys - metric_ticks) - if bad: - # A hiddenFields entry naming nothing hides nothing — and reads as a restriction - # that is not there. - raise err(400, "bad_perms", - f"{key}: hiddenFields names unknown fields {bad}") - tree = raw.get("filter") - clean_tree = None - if tree not in (None, {}, []): - if not isinstance(tree, dict) or not isinstance(tree.get("nodes"), list): - raise err(400, "bad_filter", - f"{key}: filter must be {{conj?, nodes:[…]}} — a bare list would lose " - f"the root conjunction, and an 'or' wall read as 'and' restricts " - f"nothing it was meant to") - conj = tree.get("conj", "and") - if conj not in ("and", "or"): - raise err(400, "bad_filter", f"{key}: conj must be 'and' or 'or'") - nodes = tree["nodes"] - # ⛔⛔ THE CASCADE DOES **NOT** RUN ON THE SUBMITTED TREE, AND W40-T05 SHIPPED THE - # OPPOSITE FOR AN HOUR. It pruned here first, so that an administrator with a stale - # tab would not be told their own saved rule was a client error. Sympathetic, and - # WRONG: `verify_api`'s leg *"⛔ a filter naming an unknown FIELD -> 400, never - # saved-minus-the-bad-leaf"* went red on the very case it exists for — a PUT naming - # `ghost` was accepted and stored minus that leaf. This validator is a SECURITY door, - # and the sentence the refusal below already carried says why: a wall saved with its - # bad conditions silently removed is WEAKER THAN THE ONE ON SCREEN. - # - # ⭐ SO THE CASCADE LIVES ON THE **STORED** RECORD ONLY, in `get_perms` (see - # `_prunable_vocabulary`'s own docstring). That is the honest split, and it is enough - # for I16: the leaf disappears when the record is next read, which is the same page - # load a stale tab has to do anyway. A leaf in a payload the client just SENT is - # refused, loudly, whatever its history — the server cannot tell a deleted column from - # a typo in a submission, and only one of those two guesses is safe. - # ⛔ AN EMPTIED FILTER IS `None`, NEVER `{"conj": "and", "nodes": []}`. Measured: - # `filter_eval.permits` returns True on an empty node list, so an empty-but-present - # tree admits every row — while `perm_scope.wall_declared` and `row_scope_applies` - # both read it as TRUTHY and answer that a wall applies. A door would then build rows - # in order to filter them against nothing, and the editor would paint a rule that is - # not there. This also normalises a client that PUTs `{"nodes": []}` to mean "no - # filter", which is what it has always meant on screen. - if nodes: - # Named before the generic refusal below, because "an unknown field" is exactly - # what this is NOT: the picker offered it one request ago. The admin needs to be - # told which column and why, or they will simply try again. - blind = sorted(_leaf_col_ids(nodes) & row_wall_blind) - if blind: - raise err(400, "bad_filter", - f"{key}: a permission filter cannot use {blind}. Those columns are " - f"stored per record on top of this database rather than in it, and " - f"the row wall runs before they are merged, so the rule would hide " - f"EVERY row from this account with nothing on screen saying why. " - f"Hide the column instead, or filter on one of the database's own " - f"columns.") - cleaned = aios_grid.clean_filter_tree(nodes, filter_keys, cohort_ids=None) - if _leaf_count(cleaned) != _leaf_count(nodes): - raise err(400, "bad_filter", - f"{key}: the filter contains conditions this module cannot evaluate " - f"(an unknown field, an unknown operator, or a cohort leaf — cohorts " - f"are per-user and cannot be a permanent rule). Refused rather than " - f"saved with the bad conditions silently removed, which would store " - f"a weaker wall than the one on screen.") - clean_tree = {"conj": conj, "nodes": cleaned} - # ⭐⭐ W38-T19 — `metrics` IS THE FOURTH FIELD OF AN ENTRY, AND ITS DEFAULT IS GRANT. - # `raw.get("metrics", True)` rather than a required key: a PUT composed by an older - # client, or a copy of a record written before this ticket, must not read as a - # revocation. `perm_scope.may_metrics` applies the identical rule on the read side, so - # the validator and the wall cannot disagree about what an absent key means. - out[key] = {"access": bool(raw.get("access", True)), - "filter": clean_tree, - "hiddenFields": sorted(submitted), - "metrics": bool(raw.get("metrics", True))} - _refuse_unshapeable_bu(out) - return out - - -def _refuse_unshapeable_bu(perms_out): - """⛔ A BU CONDITION THE PUSHDOWN CANNOT READ IS A VALUE LEAK WEARING A CORRECT ROW LIST. - - Amendment 3 in one more place. `perm_scope.derive_pool_scope` recognises exactly the shapes - that PIN a business unit: a top-level `dba eq` leaf, or a top-level OR-group of them — which - is what `perm_migrate` emits and what the condition builder produces for "is any of". An - admin composing the same intent a slightly different way (a NESTED group, `dba neq Royal`, a - `dba` leaf one level down) produces a filter that still narrows the ROWS correctly through - `permits()` — and leaves the pool built CONSOLIDATED, so every surviving row carries - Fisch+Royal numbers. The row list looks right. The revenue is another BU's. - - That is precisely the defect amendment 3 exists for, re-entering through the editor R9 just - shipped, and it cannot be caught downstream: by then the numbers are simply wrong, with - nothing anomalous about them. - - So it is refused at the WRITE, where a person is present to fix it. The discriminator is - narrow on purpose — the filter must MENTION `dba` and must fail to pin a team. A wall like - `dba is Fisch OR revenue > 1000` genuinely does not confine anyone to one BU, and - consolidated values are the correct answer for it, so it is not caught here (`derive` also - refuses it) and must not be. - """ - import core.perm_scope as perm_scope - - for key, e in perms_out.items(): - tree = e.get("filter") - if not tree or not _mentions_dba(tree.get("nodes")): - continue - probe = {"role": "user", "perms_v": perm_scope.PERMS_VERSION, - "perms": {key: {"access": True, "filter": tree, "hiddenFields": []}}} - team_id, _ = perm_scope.derive_pool_scope(probe, key) - if team_id is None and _confines_to_one_bu(tree): - raise err(400, "bu_condition_shape", - f"{key}: this filter restricts the business unit in a way the data layer " - f"cannot push into the query, so the rows would be correct while the " - f"revenue figures on them stayed consolidated across both units. Express " - f"the business-unit condition as a TOP-LEVEL condition — 'DBA is Fisch', " - f"or an 'any of' over the brands — and keep any other rules alongside it.") - - -def _guard_bu_widening(rec, cleaned, confirmed): - """⛔ THE TWO-CLICK LEAK: a SECOND save that silently drops the business-unit condition. - - `_fold_legacy_scope` runs only while a record is un-migrated, which is correct — after the - first save the filter IS the whole wall and the admin owns it. But the first save also - CHANGES the tree (it folds the BU condition in), so a client holding the tree it submitted - rather than the one the server returned will, on its next save, PUT a payload with no BU - condition — and a whole-record replace deletes it. Measured, not theorised: the same client - payload saved twice takes the pool scope from `(6, 'Ann')` to `(None, 'Ann')`, which is both - business units. - - So a save that REMOVES the brand confinement is refused unless it says it means to. Widening - BU access is a legitimate thing for an administrator to do — it is just never a thing to do - by accident, and the difference between the two is one explicit flag. - - ⚠ Deliberately NOT solved by always folding the legacy scope: that would make a BU - restriction permanent and unremovable, which contradicts R1 (the filter is the wall, and the - admin edits it). The right shape is "you may widen, say so". - """ - import core.perm_scope as perm_scope - - if confirmed: - return - stored = (rec.get("perms") or {}) if isinstance(rec.get("perms"), dict) else {} - for key, new_entry in cleaned.items(): - if not new_entry.get("access", True): - # Revoking the module entirely is the OPPOSITE of widening, and the filter on a - # denied entry governs nothing. Guarding it would make "lock this account out" - # require a confirm_widen flag, which reads as nonsense to whoever hits it. - continue - old = stored.get(key) - if not isinstance(old, dict): - continue # nothing stored yet: the fold already handled it - old_tree, new_tree = old.get("filter"), new_entry.get("filter") - if not old_tree or not _confines_to_one_bu(old_tree): - continue # was not confined -> this save cannot widen it - if new_tree and _confines_to_one_bu(new_tree): - continue # still confined -> not a widening - raise err(400, "bu_widening", - f"{key}: this would REMOVE the business-unit restriction and give the account " - f"both units. If that is intended, resend with confirm_widen: true. If it is " - f"not, reload the account's permissions first — saving a filter loaded before " - f"the last change drops the conditions it did not know about.") - # A guard is only as good as its trigger: `perm_scope` is imported so a future reader sees - # the connection to derive_pool_scope, which is what the removed condition was feeding. - _ = perm_scope - - -def _mentions_dba(nodes): - for n in nodes or (): - if not isinstance(n, dict): - continue - if isinstance(n.get("children"), list): - if _mentions_dba(n["children"]): - return True - elif n.get("colId") == "dba": - return True - return False - - -def _confines_to_one_bu(tree): - """Does the BRAND STRUCTURE ALONE confine the reader to one business unit? - - ⚠ "Alone" is the whole precision of this function, and getting it wrong makes the guard - refuse legitimate walls. `dba is Fisch OR ar_open > 1000` does NOT confine anybody — a - high-balance Royal customer satisfies it — so consolidated values are the correct answer and - refusing it would be a false positive. My first attempt evaluated synthetic rows carrying - only `dba`, which made `ar_open > 1000` read false on every probe row and turned that exact - wall into a "confinement" it is not. - - So every NON-`dba` leaf is treated as TRUE — the most generous reading, i.e. "suppose the - reader satisfies everything else; can they still see both brands?" If yes, the wall does not - confine and the pool is correctly consolidated. If no, the brand structure is doing the - confining and `derive_pool_scope` must be able to see it, or the numbers are wrong. - """ - def admits(node, brand): - if isinstance(node.get("children"), list): - kids = [admits(k, brand) for k in node["children"] if isinstance(k, dict)] - if not kids: - return True - return any(kids) if node.get("conj") == "or" else all(kids) - if node.get("colId") != "dba": - return True # every other condition: assume satisfied - want = str(node.get("value") or "").strip().lower() - op = node.get("op") - if op == "eq": - return brand.lower() == want - if op == "neq": - return brand.lower() != want - if op == "contains": - return want in brand.lower() - if op == "doesNotContain": - return want not in brand.lower() - if op == "isEmpty": - return False # a probe brand is never blank - if op == "isNotEmpty": - return True - return True # an op that cannot judge a brand does not confine - - nodes, conj = (tree.get("nodes") or []), tree.get("conj", "and") - root = {"conj": conj, "children": nodes} - admitted = {b for b in ("Fisch", "Royal", "Both") if admits(root, b)} - if not admitted: - return False # admits nothing at all — a different problem - # 'Both' belongs to either unit, so it cannot by itself widen the answer. - return not ({"Fisch", "Royal"} <= admitted) - - -def _leaf_count(nodes): - n = 0 - for node in nodes or (): - if isinstance(node, dict) and isinstance(node.get("children"), list): - n += _leaf_count(node["children"]) - else: - n += 1 - return n - - -def _fold_legacy_scope(rec, cleaned): - """⛔ MIGRATING A RECORD MUST NEVER WIDEN IT. The invariant this function exists to hold. - - Writing perms stamps `perms_v: 1`, and that marker SWITCHES OFF the legacy `bus`/`agent` - fallback in `perm_scope` (amendment 4 — absence must start meaning DENY). So a first write - whose filter carries no BU condition silently promotes a Royal-only account to BOTH business - units: the wall that used to come from `bus` is gone and nothing replaced it. - - Caught by the end-to-end leg in `verify_api` and by nothing else — every model-level check - passed, because the model was doing exactly what it was told. The account simply received - the other BU's customers on the wire. - - So on the FIRST perms write we fold the legacy scope in, as ordinary conditions, exactly as - `perm_migrate` would: this IS R1's migration, performed at the moment the record acquires a - perms block. From then on the admin sees those leaves in the editor (GET returns the folded - filter) and can change them like any other condition — which is R1's whole point, and why - this is a one-time fold rather than a permanent floor AND-ed on every write. - - Idempotent by construction: it runs only while the record is un-migrated, and the write that - calls it is the write that migrates it. - - ⛔ THE FOLD IS PER MODULE, BECAUSE THE LEGACY FILTER IS CUSTOMER-SHAPED AND THE MODULES ARE - NOT. `perm_migrate.filter_for` speaks `dba` and `agent` — both CUSTOMER columns. `product_data` - has neither (19 fields, no brand column at all), and `apply_row_scope` evaluates the permanent - filter with `permits()`, which DENIES anything unanswerable. So folding the customer wall into - the product entry does not narrow the product grid, it EMPTIES it: measured at 0 rows of 2 for - a `bus:[5]` account the moment an admin pressed Save. - - Nothing was wrong upstream — `_clean_perms` already refuses a `dba` condition typed against - `product_data` (its leaf keys are validated per topic). This fold was the ONLY door a - cross-topic leaf could come through, which is why it is the only place that needs the rule. - - ⚠ WHAT IS LOST HERE IS RECOVERED IN `perm_scope.derive_pool_scope`, NOT DISCARDED. Dropping the - BU condition from an AND root WIDENS that module's row scope, and for the product grid the BU - is a VALUES question anyway (`team_id` shapes `rev_ytd`/`qty_ytd`/`inv_value` — amendment 3). - The pushdown falls back to the record's own `bus` for exactly the modules whose field - vocabulary cannot express a BU, so the product pool is still BUILT as Fisch. The two halves - ship together or the second one is a leak. - """ - import core.perm_migrate as perm_migrate - import core.perm_scope as perm_scope - - if perm_scope.is_migrated(rec): - return cleaned - legacy = perm_migrate.filter_for(rec) - if not legacy: - return cleaned # nothing to preserve: the account was unrestricted - out = {} - for key, e in cleaned.items(): - legacy_here = _prune_to_module(legacy, {f["key"] for f in _module_fields(key)}) - tree = e.get("filter") - if not legacy_here: - # This module cannot evaluate ANY of the legacy conditions. Fold nothing rather than - # storing a wall it will read as "deny every row". - out[key] = e - continue - if not tree or not tree.get("nodes"): - folded = legacy_here - else: - # AND the two roots together. The submitted tree keeps its own conjunction by being - # nested as a GROUP — flattening an `or` tree into an `and` root would turn the - # admin's "A or B" into "A and B", which is a different and much narrower wall. - folded = {"conj": "and", - "nodes": list(legacy_here["nodes"]) + [{"conj": tree.get("conj", "and"), - "children": list(tree["nodes"])}]} - out[key] = dict(e, filter=folded) - return out - - -def _prune_to_module(tree, valid_keys): - """`tree` with every leaf this module cannot evaluate removed, or None if nothing survives. - - Used ONLY on the legacy fold above, where the alternative to pruning is a filter that denies - every row. It is not a general sanitiser: `_clean_perms` REFUSES rather than drops, for the - reason its own docstring gives (a silently-weakened wall reads as "Saved"). - - ⚠ A GROUP THAT LOSES ANY CHILD IS DROPPED WHOLE. Half of an `or` group is a narrower rule than - the admin's intent and half of an `and` group is a wider one; neither is the thing that was - written, and a fold has no standing to invent a third meaning. All-or-nothing per group keeps - the surviving conditions ones somebody actually declared. - """ - if not isinstance(tree, dict): - return None - - def keep(node): - if not isinstance(node, dict): - return None - kids = node.get("children") - if isinstance(kids, list): - surviving = [keep(k) for k in kids] - if not kids or any(s is None for s in surviving): - return None - return dict(node, children=surviving) - return node if node.get("colId") in valid_keys else None - - nodes = [n for n in (keep(n) for n in tree.get("nodes") or ()) if n is not None] - return {"conj": tree.get("conj", "and"), "nodes": nodes} if nodes else None - - -@router.get("/admin/users/{username}/perms") -def get_perms(username: str, session: Session = Depends(admin_gate)): - """This account's permission block plus the field schema the editor needs to render it.""" - reg = _registry() - uname = str(username or "").strip().lower() - if uname not in reg or _tenant_of(reg[uname]) != _tenant_of(session.user): - # Wave 18 (C1-TENANT): a cross-tenant username answers exactly like a missing one — - # 404, never 403, because "that account exists in another company" is itself a leak. - raise err(404, "no_such_user", f"no account named {uname!r}") - import core.perm_scope as perm_scope - - rec = reg[uname] - stored = rec.get("perms") or {} - # S3 ask 3 — AN ENTRY FOR EVERY DECLARED MODULE, never a gap the client has to default. - # The PUT is a whole-record replace over this same module list, so a key declared here and - # omitted from `perms` would become an explicit DENY the moment anyone pressed Save. The - # default sent is the EFFECTIVE one (`may_access`), so an un-migrated record shows the - # access its legacy grant currently gives rather than a guess in either direction. - # - # ⭐⭐ WAVE 33 (W33-T33, owner item 11) �� THE LIST IS THIS TENANT'S, NOT A LITERAL. Everything - # below was built from `_PERM_MODULES` and never touched `session.runtime`, which is why a - # nurilab or gtmlab admin opened Manage user and saw Royal Imports' Customer and Product - # databases. `_perm_modules` applies the tenant's own provisioning (the rule `routes_nav` - # has applied since wave 18) and merges the tenant's own `ut_*` databases. - modules = _perm_modules(session, surfaces=True) - # ⭐⭐ W36-T22 / C2 — EVERY listed database, not an `enforced` subset of them. See - # `perms.tenant_governable_modules` for why the flag is deleted rather than defaulted. - governed = [m["key"] for m in modules] - # ⭐ ONE `_module_fields` PASS, SHARED BY THE PICKER AND THE CASCADE. It used to be built - # inline at the return; hoisted so the prune below reads the SAME vocabulary the payload - # offers rather than paying a second store round-trip per database to ask again. - fields_by_module = {k: _module_fields(k, session=session) for k in governed} - # ⭐⭐ W40-T05 / OWNER I16 — THE CASCADE, AT THE ADMIN DOOR. *"If the field is deleted, its - # permission filter goes with it."* A stored leaf naming a column the database no longer has - # is not ignored by the wall — `apply_row_scope` uses `permits`, which DENIES what it cannot - # answer — so a deleted column silently converts that account's grid to zero rows. Dropping - # the leaf here means the editor renders, and the record stores, only rules that still exist. - # - # ⛔⛔ GUARDED ON `is_migrated`, AND THAT GUARD IS A BU LEAK AWAY FROM OPTIONAL. - # `users.set_access(perms=…)` STAMPS `perms_v` in the same read-modify-write, so writing here - # would MIGRATE an un-migrated record — and `_fold_legacy_scope` runs only WHILE a record is - # un-migrated. A GET would then consume the one chance to fold the legacy `bus`/`agent` scope - # in, and the account would silently acquire the other business unit's customers. The - # `stored` test alone would very nearly cover it (an un-migrated record carries no perms - # block), so the marker is asked explicitly rather than inferred from an empty dict. - if stored and perm_scope.is_migrated(rec): - _pruned, _gone = _prune_stale_filters(stored, session=session) - if _gone: - users.set_access(uname, perms=_pruned) - # Re-read rather than trust the write: this route's whole job is to report what is - # STORED, and a store that took nothing must not be reported as if it had. - rec = _registry().get(uname) or rec - stored = rec.get("perms") or _pruned - perms_out = {} - for k in governed: - e = stored.get(k) - # ⛔⛔ W36-T22 — THE DEFAULT IS `may_read`, NOT `may_access`, AND THE DIFFERENCE IS AN - # OUTAGE. `may_access` reads migrated-and-undeclared as DENY. That is right for a registry - # topic and catastrophic for a `ut_*` key, because no `ut_*` entry was STORABLE before this - # wave — so every migrated account carries none, and this route would default every one of - # them to `access: false`. - # - # ⛔ AND IT WOULD NOT HAVE STAYED A DISPLAY BUG FOR LONG. C2 also deleted the filters that - # kept `ut_*` keys OUT of the PUT body, so the editor now sends every declared module: an - # administrator opening this page, changing one unrelated filter and pressing Save would - # write an EXPLICIT `{access: false}` for all ten keychain databases — at which point - # `may_read`'s deny-only overlay fires and revokes them for real. One click, silent, - # permanent. Defaulting from the SAME evaluator the read door uses is what makes the page - # show what the user can actually open, so a save of an untouched payload is a no-op. - # ⚠ `_public(uname, rec)`, NOT the raw record. A stored record is keyed BY username in - # `users.json` and does not carry one INSIDE it, so `may_read` would hand `may_open` a - # `None` viewer and get a fail-closed False — the very outage this line exists to prevent, - # arriving through the fix for it. The same trap cost a gate double an hour earlier today. - # ⛔⛔ A SURFACE KEY DEFAULTS FROM `nav_may_open`, NOT `may_read`, AND THE - # DIFFERENCE IS A MASS REVOCATION. `may_read` on a non-`ut_` key falls through to - # `may_access`, which reads migrated-and-undeclared as DENY — correct for a topic - # (every save writes topic entries) and false for a surface, because NO surface entry - # was storable before 2026-08-18, so every migrated account carries none. The editor - # would paint Assistant unchecked for an account that opens it every day, and the next - # save of ANY unrelated change would write the explicit deny for real. `nav_may_open` - # is the evaluator the nav and the route gate actually ask, legacy fallback included, - # so the box shows what the account can reach and an untouched save stays a no-op. - _default = (perm_scope.nav_may_open(users._public(uname, rec), k) - if any(m.get("surface") and m["key"] == k for m in modules) else - perm_scope.may_read(users._public(uname, rec), k, st=session.runtime)) - # ⭐⭐ W38-T19 — `metrics` RIDES EVERY ENTRY, BACKFILLED TO GRANTED ON A RECORD WRITTEN - # BEFORE THE KEY EXISTED. The wire is TOTAL on purpose: the client's own parse already - # reads an absent key as granted, so this line changes no rendering — what it changes is - # what a HUMAN reads off the payload while debugging, and what the PUT round-trips. The - # stored dict is copied rather than mutated: `rec` is the live registry record and - # stamping a key onto it here would write a permission nobody saved. - perms_out[k] = (dict(e, metrics=bool(e.get("metrics", True))) - if isinstance(e, dict) else - {"access": bool(_default), "filter": None, "hiddenFields": [], - "metrics": True}) - return {"username": uname, - "perms": perms_out, - # S3 ask 2 — the marker rides the GET. An absent module entry means DENY on a - # migrated record and LEGACY on an un-migrated one: opposite meanings for identical - # JSON, so the client cannot render honestly without knowing which world it is in. - "perms_v": int(rec.get("perms_v") or 0), - # `role == 'admin'` bypasses perms entirely (amendment 4) — sent so the editor can - # say "Everything (admin)" instead of rendering stored rules that do not apply. - "is_admin": perms.is_admin(rec), - # ⭐⭐ W36-T22 / C2 — `{key, label}` per row and NO `enforced`. It used to ride here - # so the editor could paint an apology ("Not set here") for a database whose wall was - # never applied; W36-T21 armed every one of them, so the flag would now be constantly - # true and the apology would be a lie with a green gate behind it. The LABEL comes - # from the registry / the table's own definition. - "modules": modules, - # S3 ask 1 — CANONICAL SHAPE: a BARE ARRAY of fields per module key. Not the nav - # schema envelope; the editor needs the field list and nothing else, and one shape - # beats two readings of a sentence. - # ⭐ EVERY governed database now carries its fields, `ut_*` included — that is owner - # item 11's *"EVERY database should be able to be toggleable by admin"*, and it is - # the SAME resolver `_clean_perms` validates against, so the picker cannot offer a - # column the validator will reject. - "fields_by_module": fields_by_module} - - -@router.put("/admin/users/{username}/perms") -def put_perms(username: str, body: dict = Body(default=None), - session: Session = Depends(admin_gate)): - """Replace this account's permission block wholesale. - - WHOLESALE, not a merge: "remove this restriction" has to be expressible, and a merge cannot - express a deletion without a second vocabulary for it. - - ⚠ NO EPOCH BUMP, for the same reason `PATCH /admin/users/{u}` does not bump one: - `deps._user_for` re-reads the record on every request, so a narrowed wall applies to the - target's LIVE session on its very next call. Bumping would only add a forced re-login on top - of a change that has already taken effect. - """ - body = body if isinstance(body, dict) else {} - reg = _registry() - uname = str(username or "").strip().lower() - if uname not in reg or _tenant_of(reg[uname]) != _tenant_of(session.user): - # Wave 18 (C1-TENANT): a cross-tenant username answers exactly like a missing one — - # 404, never 403, because "that account exists in another company" is itself a leak. - raise err(404, "no_such_user", f"no account named {uname!r}") - if "perms" not in body: - raise err(400, "empty_patch", "no perms to save") - - # THE SELF-LOCKOUT GUARD, the twin of `self_demote` on the PATCH route. An admin bypasses - # perm_scope entirely, so this cannot brick them today — but it stops an administrator - # writing a wall for themselves that would bite the moment their role changed. - if uname == session.uname: - raise err(400, "self_perms", - "you cannot set permissions on your own account — ask another admin") - - _mods = _perm_modules(session, surfaces=True) - # ⭐⭐ W36-T22 / C2 — ONE key set. It was two ("listed" and "enforced") with a refusal in the - # gap; W36-T21 closed the gap, so every database the editor shows is one whose wall the table - # routes apply. `session` rides so a `ut_*` schema can be read for THIS tenant. - cleaned = _clean_perms(body.get("perms"), - governed_keys={m["key"] for m in _mods}, session=session) or {} - cleaned = _fold_legacy_scope(reg[uname], cleaned) - _guard_bu_widening(reg[uname], cleaned, bool(body.get("confirm_widen"))) - users.set_access(uname, perms=cleaned) - fresh = _registry() - rec = fresh.get(uname) or {} - if int(rec.get("perms_v") or 0) < users.PERMS_VERSION: - # The store took the write and did not record it. A 200 here would tell an administrator - # the wall is up when it is not — the one direction this must never fail in. - raise err(503, "store_unavailable", - "the permissions were not saved — the account is UNCHANGED") - return {"username": uname, "perms": rec.get("perms") or {}, - "perms_v": int(rec.get("perms_v") or 0)} - - -def _store_binding(session): - """`data_binding.describe()` for the store THIS SESSION's tenant actually writes. - - ⭐ THE TENANT'S STORE, NOT THE PROCESS DEFAULT, AND THAT DISTINCTION IS THE POINT. Tenant #0 - rides `core.store`'s module default, but nurilab/gtmlab/loopable are bound to their OWN dataset - repos by `harness/runtime.py:470`, straight out of the control-plane record — which is the path - that carried three tenants' PRODUCTION stores onto staging while `--data-repo` isolated only - tenant #0. Reporting the process default here would show "staging store, all fine" to exactly - the tenants that were not fine. - - ⚠ Degrades to a stated `unknown` rather than raising: a settings page that 500s because it - could not describe the store is worse than one that says it does not know. - """ - try: - import core.data_binding as data_binding # noqa: PLC0415 - import core.store as store # noqa: PLC0415 - bound = getattr(session.runtime, "store_handle", None) - repo = getattr(bound, "repo", None) or store.REPO - return data_binding.describe(repo) - except Exception as e: # noqa: BLE001 - return {"deployment": "unknown", "production": False, "repo": "", - "writable": False, "refusal": f"could not resolve the store binding: {e}", - "localOverride": False, "allowlist": [], "observed": {}} - - -@router.get("/settings") -def settings(session: Session = Depends(require_session)): - """The session's OWN settings — any authenticated user, not admin-only. - - `user` comes from `routes_auth._public_user`, deliberately reusing the ONE definition of what a - client may know about itself (never a hash, never the epoch). `scope` restates it as the two - things the Settings UI shows, and `tenant` names only THIS tenant — enumerating the others - would answer a question about our customer list. - """ - modules = perms.allowed_modules(session.user) - return { - # The build this Space is running. Behind the session on purpose — see `main.py::health`, - # which refuses it for being the first URL a scanner finds. `deploy_web.py` stamps it as a - # Space secret at every deploy; absent means "somebody started this container by hand". - "version": os.environ.get("AIOS_VERSION") or "unknown", - "user": _public_user(session.user), - "scope": { - "bus": perms.allowed_bu_labels(session.user), - "team_id": perms.scope_team_id(session.user), - "agent": perms.scope_agent(session.user), - "modules": sorted(modules) if modules is not None else "all", - # C-PERM: the caller's OWN effective wall, read-only. The grid uses it to grey - # pickers rather than to enforce — hidden fields are stripped from every data wire - # regardless, so a client that ignores this is narrowed anyway, never widened. - "perms": session.user.get("perms") or {}, - }, - "tenant": {"key": session.tenant, "name": getattr(session.runtime, "name", - session.tenant)}, - # ⭐⭐ D-315 — WHICH STORE THIS CONTAINER IS BOUND TO, AND WHETHER IT MAY WRITE IT. - # - # ⛔ THIS IS NOT DIAGNOSTIC GARNISH; IT IS THE ONLY WAY THE QUESTION CAN BE ANSWERED. - # D-160 established that a Space's environment cannot be read from outside, so "is staging - # pointed at production data?" is a question only the container can answer — and it went - # unanswered for the whole window in which two builds wrote tenant #0's real store. - # `verify_live` asserts this field after logging in, which is a check that survives a role - # swap in a way that reading a deploy log never did. - # - # ⚠ Session-gated, beside `version`, for `main.py::health`'s reason: a Space id and a - # dataset repo id are public names, but a health endpoint is the first URL a scanner finds - # and it may not describe the deployment. No customer data is here — only which store, and - # yes or no. - "store": _store_binding(session), - # CHROME ONLY. Every /admin route re-checks the role server-side; this exists so the client - # does not paint a "Manage users" button that 403s. - "admin": perms.is_admin(session.user), - # Wave 19 (R3, contract C2): the same courtesy for the LOOPABLE plane — the Settings rail - # paints its entry iff this is true, and `routes_platform_admin` refuses every request - # that is not, so a client that ignored this would gain exactly nothing. Derived from the - # ONE predicate rather than restated: a second copy of a two-condition wall is a second - # place for one of the conditions to go missing. - "platformAdmin": platform_admin.is_platform_admin(session.user), - } +"""routes_admin.py — Y4: user administration + the session's own settings (W2-3). + +These routes are the standalone shell's replacement for `app.py`'s `users_dialog` / `settings_dialog` +modals, over the SAME `core/users` account store — so both front-ends administer one set of +accounts and the eventual OIDC migration (D-3) swaps the CREDENTIAL check, not the user model. + +ADMIN-ONLY, FAIL-CLOSED. `admin_gate` is a role check (`perms.is_admin`), mirroring the Streamlit +dialog's `if not is_admin()`. A default record has `role: 'user'`, so an unreadable or partial record +is denied rather than admitted. `verify_api.py` proves it by having a viewer TRY every route. + +⛔ THIS IS THE FIRST WRITER OF `modules` THAT HAS EVER EXISTED. `core.users.set_access(modules=…)` +has no caller anywhere in the shipped app — module grants are only settable by editing the store +JSON out of band. That matters because the record format carries TWO documented fail-OPENS, both +asserted in `verify_api.py` section A: + + * `modules: []` is FALSY, so `perms.allowed_modules` reads it as 'all' = UNRESTRICTED. An admin + clearing every checkbox to lock an account down would grant it everything. + * `bus: []` falls through `allowed_bus_labels`'s "no recognisable label" branch to + `['All','Fisch','Royal']` — so one typo'd BU id is FULL cross-BU access, in the model whose + whole point is strict isolation. + +Y4 says do not "fix" `modules: []` in this wave, and that is right: the READER is mirrored in +`ui/session.py` and `core/perms.py` and diverging one of them mid-wave breaks lock-step. But the +WRITER is new, and it can simply refuse to create either footgun. So both are 400s here and both +read semantics are untouched — the seam is write-strict / read-unchanged. + +⚠ A STORE OUTAGE IS A 503, NEVER AN EMPTY LIST. `users.registry()` swallows a failed read into `{}` +one level down, and serving that as `{"users": []}` would tell an administrator their tenant has no +accounts. Same rule as the write path: an empty 200 is never how this API says "something is wrong". +""" +import os +import re + +from fastapi import APIRouter, Body, Depends, Response + +import core.platform_admin as platform_admin # wave 19 R3 — the /settings chrome flag +import core.registry as registry +import core.store as store + +from deps import Session, err, perms, require_session, users +from routes_auth import _public_user + +router = APIRouter(prefix="/api/v1") + +#: A username is a STORE KEY (`users.json`) and also the key the table workspace is filed under +#: (`data[username]`), so it is constrained rather than trusted: lowercase, no separators, no +#: whitespace, nothing that could traverse or collide once it becomes part of a path or a filename. +_UNAME_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{1,31}$") +#: ⭐⭐ WAVE 37 · T03 (owner ruling R8) — AN EMAIL ADDRESS IS ALSO A USERNAME. Owner: the username +#: IS the email, one identity. ⛔ THIS IS NOT A NEW CAPABILITY, IT IS THE DOOR CATCHING UP WITH THE +#: DATA: measured on deployed staging before the change, FIVE of tenant #0's eight accounts already +#: carry an `@` in the `username` field (`david@fischfloralsupply.com`, `farhan@`, `florencia@`, +#: `karen@`, `naomi@`) — every one of them illegal under the slug pattern above and longer than its +#: 32-character cap, so this route could not have created any of them. +#: ⚠ DELIBERATELY PERMISSIVE, because the store key is the thing being constrained and not the +#: deliverability of the address: one `@`, a dot-bearing domain, and the same character class the +#: slug already trusts. A stricter grammar here would refuse addresses that exist. +_UNAME_EMAIL_RE = re.compile(r"^[a-z0-9][a-z0-9._%+-]*@[a-z0-9][a-z0-9.-]*\.[a-z]{2,24}$") +#: RFC 5321's cap on a whole address. The slug form keeps its own 32 via `_UNAME_RE`. +_UNAME_MAX = 254 +#: Passwords are PBKDF2-200k, which is only as strong as what it is given. The Streamlit dialog +#: enforces nothing; this does, and the two are allowed to differ because only one of them is +#: reachable from the internet. +_MIN_PW = 8 +_ROLES = ("user", "admin") + + +def admin_gate(session: Session = Depends(require_session)) -> Session: + """401 without a session, 403 without the admin role. Role, not a module grant — mirroring + `app.py`'s `users_dialog`, whose only wall is `is_admin()`.""" + if not perms.is_admin(session.user): + raise err(403, "forbidden", "administrators only") + return session + + +def _require_store(): + """A 503 the moment the store cannot serve, so no route below can report emptiness as truth.""" + try: + ok = store.available() + except Exception: + ok = False + if not ok: + raise err(503, "store_unavailable", + "the tenant store is unavailable — accounts cannot be read or changed") + + +def _registry(): + _require_store() + try: + reg = users.registry() or {} + except Exception: + raise err(503, "store_unavailable", "the tenant store is unavailable") + return reg + + +def _view(uname, rec, governed=None, st=None, surface_keys=None): + """What an administrator may see about an account. NEVER `salt` or `hash` — this function is + the only projection these routes use, so there is one place that can leak and it does not. + + ⚠ `governed`/`st` ride through to `_access_summary` and nowhere else. The ROSTER passes both, + resolved once for the whole request; the single-record routes pass neither, because their + caller fetches the editor payload separately and `GET /perms` is the authority there. + """ + return {"username": uname, + "name": rec.get("name") or uname, + "role": rec.get("role", "user"), + "bus": rec.get("bus", "all"), + "bu_labels": perms.allowed_bu_labels(rec), + "modules": rec.get("modules", "all"), + "agent": rec.get("agent") or None, + "email": rec.get("email") or None, + "tenant": _tenant_of(rec), + "active": bool(rec.get("active", True)), + # The session-revocation handle. An admin needs it: it is the only visible evidence + # that "sign them out everywhere" actually happened. + "epoch": int(rec.get("epoch") or 0), + # S3 ask 4 — one sentence for the roster's Access column, so the list does not need + # a /perms round trip per row to fill one cell. Computed from the same record the + # editor will open, so the two cannot disagree. + "accessSummary": _access_summary(rec, uname=uname, governed=governed, st=st, + surface_keys=surface_keys), + "perms_v": int(rec.get("perms_v") or 0)} + + +def _access_summary(rec, uname="", governed=None, st=None, surface_keys=None): + """One sentence describing what this account may reach. Deliberately says LESS than the + editor: it is a signpost, not a rule listing, and a summary that tried to spell out filters + would be wrong the moment a filter got interesting. + + ⛔⛔ W36-T22 — IT MUST COUNT WHAT THE EDITOR WOULD SHOW, NOT WHAT IS STORED, AND THE TWO STOPPED + AGREEING THE MOMENT `get_perms` LEARNED TO DEFAULT FROM `may_read`. This counted STORED ENTRIES + only, which WAS the same answer before this wave: a migrated account had an entry for every + governable database, because the editor wrote one per topic on every save and no `ut_*` entry + could exist at all. Now a `ut_*` key legitimately has NO entry and is still OPEN, so the roster + would say *"1 module"* about an account the editor shows reaching twelve. ⛔ The roster is the + screen an administrator reads FIRST, and two screens disagreeing about one account is the + defect this wave exists to remove, not a rounding difference. + + ⚠ `governed`/`st` ARE PASSED IN RATHER THAN DERIVED HERE, and that is a cost decision. This + runs once PER ROW of the roster; asking `may_read` per (account x database) without a lend + would be a whole-document copy per question — D-213's shape, one route over. The caller + resolves the tenant's list and ONE lend for the whole request. A caller that passes neither + keeps the stored-entry reading, which is right for the single-record routes: their client + fetches `GET /perms` separately and that payload is the authority. + """ + import core.perm_scope as perm_scope + + if perms.is_admin(rec): + # amendment 4: an admin bypasses `perms` entirely, so rendering their stored rules as if + # they applied is the one misreading of that clause that could actually hurt. + return "Everything (admin)" + if not perm_scope.is_migrated(rec): + mods = perms.allowed_modules(rec) + return "All modules (legacy rules)" if mods is None else \ + f"{len(mods)} module{'' if len(mods) == 1 else 's'} (legacy rules)" + entries = (rec.get("perms") or {}) + if governed: + principal = users._public(uname, rec) if uname else rec + # ⛔ PER KIND, MIRRORING `get_perms`' OWN DEFAULTS — the first draft asked + # `nav_may_open` for EVERY key and that was a second idea of the count: for a migrated + # record with no topic entry the editor's default is `may_read` (DENY), so a blanket + # admission read would make the roster say "3 modules" about an account whose editor + # shows one box ticked. The roster's whole contract (leg 6 of the T22 section) is that + # it counts what the editor would show; a SURFACE key defaults from admission there, + # everything else from `may_read`, so this does exactly the same, key by key. + opened = [k for k in governed + if (perm_scope.nav_may_open(principal, k, st=st) + if k in (surface_keys or ()) else + perm_scope.may_read(principal, k, st=st))] + else: + opened = [k for k, e in entries.items() if isinstance(e, dict) and e.get("access", True)] + restricted = sum(1 for k in opened + if isinstance(entries.get(k), dict) + and (entries[k].get("filter") or entries[k].get("hiddenFields"))) + if not opened: + return "No modules" + base = f"{len(opened)} module{'' if len(opened) == 1 else 's'}" + return f"{base}, {restricted} restricted" if restricted else base + + +# ── request validation: every rule below is fail-closed ────────────────────────────────────────── +def _clean_username(v): + """The ONE validator for a new account's key. R8 widened it to admit a full email address. + + ⛔ IT HAS EXACTLY ONE CALL SITE AND THAT IS LOAD-BEARING, not an oversight to tidy up. + `PATCH /admin/users/{username}`, the password route and both `/perms` routes take the username + as a PATH parameter and never re-validate it, which is precisely why the five email-keyed + accounts above are administrable today despite being unconstructable. Arming this validator on + those routes would look like hardening and would break five live accounts. + """ + uname = str(v or "").strip().lower() + if len(uname) > _UNAME_MAX or not (_UNAME_RE.match(uname) or _UNAME_EMAIL_RE.match(uname)): + raise err(400, "bad_username", + "a username is either 2 to 32 characters of lowercase letters, digits, dot, " + "dash or underscore, or a full email address of up to 254 characters") + return uname + + +def _clean_password(v): + pw = str(v or "") + if len(pw) < _MIN_PW: + raise err(400, "weak_password", f"a password must be at least {_MIN_PW} characters") + return pw + + +def _clean_role(v): + role = str(v or "").strip().lower() + if role not in _ROLES: + raise err(400, "bad_role", f"role must be one of {list(_ROLES)}") + return role + + +def _clean_bus(v): + """'all', or a non-empty list of KNOWN business-unit ids. + + Refusing an unknown id is the whole point: `allowed_bus_labels` treats a list with no + recognisable label as full access, so `bus: [7]` would be a cross-BU grant created by a typo. + """ + if isinstance(v, str): + if v.strip().lower() == "all": + return "all" + raise err(400, "bad_bus", "bus must be 'all' or a list of business-unit ids") + if not isinstance(v, (list, tuple)) or not v: + raise err(400, "bad_bus", + "bus must be 'all' or a NON-EMPTY list of business-unit ids (an empty list " + "would read as unrestricted)") + out = [] + for b in v: + try: + b = int(b) + except (TypeError, ValueError): + raise err(400, "bad_bus", "a business-unit id must be a number") + if b not in users.BU_LABELS: + raise err(400, "bad_bus", + f"{b} is not a known business unit {sorted(users.BU_LABELS)} — an " + "unrecognised id would widen access to every BU") + out.append(b) + return sorted(set(out)) + + +def _clean_modules(v): + """'all', or a non-empty list of KNOWN module keys. + + `[]` is refused because it READS as unrestricted (see the module docstring). An unknown key is + refused because `may_open` is fail-closed on it: an admin who typed `sale` for `sales` would + silently lock the account out of the page they meant to grant. + """ + if isinstance(v, str): + if v.strip().lower() == "all": + return "all" + raise err(400, "bad_modules", "modules must be 'all' or a list of module keys") + if not isinstance(v, (list, tuple)) or not v: + raise err(400, "bad_modules", + "modules must be 'all' or a NON-EMPTY list of module keys — an empty list " + "reads as UNRESTRICTED, which is the opposite of locking an account down") + known = set(registry.BY_KEY) | set(perms._LEGACY_KEYS) + out, bad = [], [] + for k in v: + k = str(k or "").strip() + (out if k in known else bad).append(k) + if bad: + raise err(400, "bad_modules", f"unknown module keys: {sorted(bad)}") + return sorted(set(out)) + + +def _tenant_of(rec): + """The account's company, with the pre-wave default. ONE spelling of the default, used by + every admin route — two spellings is how a tenant wall grows a gap.""" + return str((rec or {}).get("tenant") or "royal-imports").strip().lower() + + +# ── the routes ─────────────────────────────────────────────────────────────────────────────────── +@router.get("/admin/users") +def list_users(session: Session = Depends(admin_gate)): + """Wave 18 (C1-TENANT): scoped to the CALLER'S tenant. The user registry is a global + control-plane bucket, so without this filter a Nurilab admin would read Royal's whole + roster — names, emails, agents — from one URL.""" + reg = _registry() + mine = _tenant_of(session.user) + # ⭐ W36-T22 — the tenant's governable list and ONE lend, resolved once for the whole roster so + # `_access_summary` can agree with the editor without paying a document copy per question. + import core.user_tables as _ut + try: + _rows = _perm_modules(session, surfaces=True) + governed = [m["key"] for m in _rows] + surface_keys = {m["key"] for m in _rows if m.get("surface")} + lent = _ut.lend_defs(session.runtime) + except Exception: # noqa: BLE001 + # A store that cannot list the tenant's databases must not take the roster down. The + # summary degrades to the stored-entry reading, which is what it always was. + governed, lent, surface_keys = None, None, None + return {"users": [_view(u, reg[u], governed=governed, st=lent, surface_keys=surface_keys) + for u in sorted(reg) + if _tenant_of(reg[u]) == mine]} + + +@router.post("/admin/users", status_code=201) +def create_user(body: dict = Body(default=None), session: Session = Depends(admin_gate)): + """Create an account. **409 if the username already exists — `PATCH` is the update path.** + + ⛔ WHY 409 AND NOT AN UPSERT. `core.users.create_user` writes a fresh `_record()`, and a fresh + record has NO `epoch` — so overwriting an existing account used to drop its epoch back to 0 and + RESURRECT every cookie minted before its last password change. `core/users.py` now + preserves-and-bumps on overwrite (which also closes it for `app.py`'s dialog, where "Add / + update a user" calls the same function), but this route still refuses: an upsert that silently + replaces an account's password, role and BU access because someone reused a username is not an + update anybody asked for. + """ + body = body or {} + _require_store() + uname = _clean_username(body.get("username")) + pw = _clean_password(body.get("password")) + role = _clean_role(body.get("role") or "user") + bus = _clean_bus(body.get("bus") if body.get("bus") is not None else "all") + modules = _clean_modules(body.get("modules") if body.get("modules") is not None else "all") + name = str(body.get("name") or "").strip() or uname + agent = str(body.get("agent") or "").strip() or None + email = str(body.get("email") or "").strip() or None + + _reg_before = _registry() + if uname in _reg_before: + raise err(409, "user_exists", f"{uname} already exists — PATCH it to change it") + # ⭐⭐ WAVE 37 · T03 (R8) — THE COLLISION THE WIDENING CREATES, REFUSED AT CREATE TIME. + # `core/users.py::verify` resolves a typed string by looking up the registry KEY first and only + # scanning the `email` field when that misses (its own wave-18 R1 comment). That precedence was + # incidental while a username could not contain `@`; now it decides which of two accounts a + # person reaches, so it is stated in both files rather than left to be re-derived. + # ⛔ BOTH DIRECTIONS, because each one silently takes a login away from an account that has it: + # (a) a new USERNAME equal to somebody's EMAIL wins the key lookup and shadows their address; + # (b) a new EMAIL equal to somebody's USERNAME can never be reached, because their key wins. + # Refused with a named error rather than allowed and explained afterwards: the failure is a + # person signing in and landing in the wrong account, which nothing downstream can detect. + if "@" in uname: + _shadowed = next((k for k, r in _reg_before.items() + if isinstance(r, dict) + and str(r.get("email") or "").strip().lower() == uname), None) + if _shadowed: + raise err(409, "username_shadows_email", + f"{uname} is already the email address on the account {_shadowed}, which " + f"signs in with it today. This account would take that login over. Choose a " + f"different username, or clear the email address on {_shadowed} first.") + if email: + _e = email.strip().lower() + if _e != uname and _e in _reg_before: + raise err(409, "email_shadows_username", + f"{_e} is already a username on this deployment, so anyone signing in with " + f"it reaches that account and never this one. Choose a different email " + f"address for this account.") + # Wave 18 (C1-TENANT): an admin creates accounts in their OWN company, never another's — + # the tenant is stamped from the session, not taken from the body. + users.create_user(uname, pw, name, role=role, bus=bus, modules=modules, + agent=agent, email=email, tenant=_tenant_of(session.user)) + reg = _registry() + if uname not in reg: + # The store accepted the write and does not have it. Reporting 201 here would be the + # "200 over a write that evaporated" failure the whole store-outage rule exists to prevent. + raise err(503, "store_unavailable", "the account was not saved — try again") + return {"user": _view(uname, reg[uname])} + + +@router.patch("/admin/users/{username}") +def update_user(username: str, body: dict = Body(default=None), + session: Session = Depends(admin_gate)): + """Change access on an existing account. Only the keys PRESENT in the body change. + + ⚠ NO EPOCH BUMP, and that is not an omission. `deps._user_for` re-reads the record on every + request, so a narrowed role / BU / module grant applies to the target's LIVE session on its very + next call — bumping the epoch would only add a forced re-login on top. `active: false` is the + exception and it bumps, inside `core.users.set_active`, because a disabled account must lose its + session rather than keep working until the cookie expires. + """ + body = body if isinstance(body, dict) else {} + reg = _registry() + uname = str(username or "").strip().lower() + if uname not in reg or _tenant_of(reg[uname]) != _tenant_of(session.user): + # Wave 18 (C1-TENANT): a cross-tenant username answers exactly like a missing one — + # 404, never 403, because "that account exists in another company" is itself a leak. + raise err(404, "no_such_user", f"no account named {uname!r}") + if not body: + raise err(400, "empty_patch", "no fields to update") + + unknown = sorted(set(body) - {"name", "role", "bus", "modules", "active", "agent", "email"}) + if unknown: + # A silently-ignored field is how a UI ends up believing it saved something it did not. + raise err(400, "unknown_fields", f"cannot update: {unknown}") + + is_self = uname == session.uname + role = _clean_role(body["role"]) if "role" in body else None + active = bool(body["active"]) if "active" in body else None + # THE ONE-CLICK LOCKOUT GUARD. APP_PASSWORD remains the real backstop for 'admin', so this is + # not the thing that keeps the product reachable — it just stops an administrator removing + # their own access with a toggle and having to go find the master password. + if is_self and role is not None and role != "admin": + raise err(400, "self_demote", + "you cannot remove your own administrator role — ask another admin") + if is_self and active is False: + raise err(400, "self_deactivate", "you cannot deactivate your own account") + + kw = {} + if role is not None: + kw["role"] = role + if "name" in body: + kw["name"] = str(body["name"] or "").strip() or uname + if "bus" in body: + kw["bus"] = _clean_bus(body["bus"]) + if "modules" in body: + kw["modules"] = _clean_modules(body["modules"]) + if "agent" in body: + kw["agent"] = str(body["agent"] or "").strip() # '' clears the link + if "email" in body: + kw["email"] = str(body["email"] or "").strip() + if kw: + users.set_access(uname, **kw) + if active is not None and active != bool(reg[uname].get("active", True)): + users.set_active(uname, active) # bumps the epoch: kills live sessions + + fresh = _registry() + return {"user": _view(uname, fresh[uname])} + + +@router.post("/admin/users/{username}/password", status_code=204) +def set_password(username: str, body: dict = Body(default=None), + session: Session = Depends(admin_gate)): + """Rotate a password. **Revokes every outstanding session for that account** — the epoch bump + happens inside the same read-modify-write as the hash (`core.users.set_password`), so the two + can never disagree. + + 204 with no body: there is nothing to say that the caller did not already know, and echoing the + account back would invite a client to diff a response for a password change. + """ + reg = _registry() + uname = str(username or "").strip().lower() + if uname not in reg or _tenant_of(reg[uname]) != _tenant_of(session.user): + # Wave 18 (C1-TENANT): a cross-tenant username answers exactly like a missing one — + # 404, never 403, because "that account exists in another company" is itself a leak. + raise err(404, "no_such_user", f"no account named {uname!r}") + pw = _clean_password((body or {}).get("password")) + before = int(reg[uname].get("epoch") or 0) + users.set_password(uname, pw) + after = int((_registry().get(uname) or {}).get("epoch") or 0) + if after <= before: + # The whole value of this route is the revocation. If the epoch did not move, the sessions + # were not revoked, and answering 204 would say they were. + raise err(503, "store_unavailable", + "the password change did not persist — outstanding sessions were NOT revoked") + return Response(status_code=204) + + +# ── C-PERM (wave 15): per-module access + permanent filters + hidden fields ────────────────── +#: The modules the permission editor governs — the GRID surfaces, the ones with a field schema +#: to filter and hide. Amendment 6 shipped the wall on `customer_data` alone and said this list +#: is the one place that changes when C-TOPIC lands. WAVE 16: it landed, so `product_data` +#: joins — and it is not cosmetic. `perms_v: 1` means an UNDECLARED module DENIES (amendment 4), +#: so a migrated account would be locked out of Product with no control anywhere to grant it: +#: fail-closed, but unadministrable. The editor grows its section with zero client edits +#: (S3 built it off this route's `modules` + `fields_by_module`). +#: +#: ⭐⭐ WAVE 33 (owner item 11, W33-T33) — **THIS IS A CATALOGUE, NOT AN ANSWER.** It used to be +#: both, and that was the defect the owner flagged four times: `get_perms` served this tuple +#: verbatim and never touched `session.runtime`, so EVERY tenant was handed tenant #0's two grid +#: modules. What this names now is a fact about THIS FILE — the registry topics `_module_fields` +#: below has a field-schema arm for — and the answer a tenant receives is `_perm_modules(session)`, +#: which filters this by the tenant's own provisioning and merges the tenant's own `ut_*` +#: databases. ⛔ Serving this list directly again re-opens item 11; `verify_perm_scope`'s +#: `section_tenant_derived` NC does exactly that and reds. +#: ⭐ CONTRACT C3, CONSUMED (lane E's `W33-T46`, answered as `E-3`). The topic list is DERIVED from +#: `registry.governable_modules()` — `{topic: {'key','label','subject'}}` over every non-archived, +#: non-group_only registry row that declares a `topic`. What stays hand-written is +#: `_FIELD_PROVIDER_KEYS` below, and that is CODE, not policy: each name has its own `aios_grid` +#: contract behind it in `_module_fields`, and a topic with no arm cannot be governed by this +#: editor at all. The intersection is the honest answer to "what can this module build pickers for". +#: +#: ⚠ KEPT AS A SYMBOL RATHER THAN DELETED, deliberately. `verify_api`'s W30a reads +#: `routes_admin._PERM_MODULES` to assert `perm_scope.BU_FILTERABLE_MODULES` names exactly the +#: governed topics whose contract carries `dba`. Deleting the name would make that gate raise +#: `AttributeError` — a CRASH, which scores as "the gate is broken" rather than as a red +#: [[gate-must-go-red-not-crash]] — and `verify_api.py` is lane A's fence. Derived-and-kept gives +#: that assertion a truer subject than the literal ever did, and costs nobody a cross-fence edit. +_FIELD_PROVIDER_KEYS = frozenset({"customer_data", "product_data"}) + + +def _governable_catalogue(): + """The REGISTRY topics this module can govern: `[{key, label}]`, in registry order. + + ⛔ No tenant filtering here — that is `perms.tenant_governable_modules`' job, and E's map is + tenant-blind by design. Two filters in two files answering one question is how the perms route + and `routes_nav` came apart in the first place. + """ + import core.registry as registry + return [{"key": v["key"], "label": v["label"]} + for v in registry.governable_modules().values() + if v.get("key") in _FIELD_PROVIDER_KEYS] + + +_PERM_MODULES = tuple(m["key"] for m in _governable_catalogue()) + + +def _perm_modules(session, surfaces=False): + """The databases THIS tenant's permission editor governs: `[{key, label, enforced}]`. + + Three inputs, none of them a literal: the topic catalogue above, the tenant's provisioned + module set (`tenant.config['modules']`, exactly the rule `routes_nav.py::nav` applies), and + the tenant's own `ut_*` databases read through the DEFINITIONS projection. + + ⭐ `user_tables.nav_entries` rather than a fresh listing, deliberately: it is already the ONE + resolver for "which databases may this account see", it is `may_open`-filtered, and it reads + the projected document (`all_defs`) so this costs definitions, never the 28.6 MB of rows. + The caller is admin-gated, so the filter admits the whole tenant — but asking the resolver + instead of assuming that is what keeps this from becoming the SECOND idea of who may see a + table (`user_tables.may_open`'s own docstring is the record of the first time that happened). + """ + import core.perms as _perms + import core.user_tables as user_tables + try: + ut = user_tables.nav_entries(viewer=session.uname, is_admin=True, + st=session.runtime) + except Exception: + # A store that cannot list the tenant's databases must not take the whole editor down — + # the topic half is still administrable, and an empty `ut_*` half is visibly empty. + ut = () + cat = _governable_catalogue() + skeys = set() + if surfaces: + # ⭐⭐ OWNER RULING 2026-08-18 — the Assistant and Agents surfaces are + # governable ACCESS toggles in the ACCOUNT editor. `surfaces` is caller-explicit and + # defaults CLOSED because the other consumer of this list is the CHANNEL-AGENT editor + # (`routes_slack.get_channel_agent`), whose principal is enforced per DATABASE + # (`agent_rows`/`agent_may_read`) and never opens an app surface: an Assistant toggle + # there would be a stored rule nothing applies, the exact class C2 deleted. + surf = _perms.governable_surface_modules() + skeys = {s["key"] for s in surf} + cat = cat + surf + rows = _perms.tenant_governable_modules(session.runtime, cat, ut_entries=ut) + # The marker survives OUTSIDE `tenant_governable_modules`, which builds bare {key,label} + # rows: the client needs it only to choose the schemaless help copy, and the server needs + # it only to pick the admission default in `get_perms`. + return [dict(r, surface=True) if r["key"] in skeys else r for r in rows] + + +#: ⛔ `_enforced_keys` IS DELETED (W36-T22 / CONTRACT C2). It asked `perms.enforced_module_keys` +#: which subset of the listed databases a wall could actually be stored against, and after R6 the +#: answer is "all of them" — a question with one possible answer is not a question. Both it and the +#: `perms` helper behind it are gone rather than made to return everything, because a predicate +#: that ignores its argument is the always-true flag C2 deletes, one indirection along. +#: What callers use instead is `{m["key"] for m in _perm_modules(session)}` — the SAME list the +#: editor renders, so "shown" and "storable" cannot drift apart again. + + +def _module_fields(key, session=None): + """The field schema the editor builds its pickers from — the SAME vocabulary the grid uses, + so an admin filters on exactly what the user sees. + + ⛔ Returned WITHOUT any per-user hiding applied, deliberately: this is the admin choosing + what to hide, so hiding the choices would make a field unhideable the moment it was hidden + once for somebody. The route is admin-gated; the projection a MEMBER receives is the one + `perm_scope.visible_fields` narrows. + + ⚠ PER TOPIC (wave 16). `product_data` has its OWN canonical contract, and handing the + editor the customer schema for it would let an admin build a wall out of columns that + module does not have — `_clean_perms` would then 400 on the admin's own choices, or worse, + a filter naming a field the product rows lack would DENY every row via `permits()`. + The product contract is served CONSOLIDATED (every column) on purpose: a BU-scoped reader + is served fewer columns, but the ADMIN is choosing what may ever be hidden. + + ⛔⛔ WAVE 33 — THE `else` USED TO BE A FALL-THROUGH, AND IT WAS SILENT-WRONG. The body read + `aios_grid.product_fields() if key == "product_data" else aios_grid.FIELDS`, so ANY key that + was not `product_data` got the CUSTOMER contract: the moment the governed list stopped being + a two-element literal, a `ut_*` database or a third topic would have been handed customer + columns, `_clean_perms` would have validated an admin's `hiddenFields` against them, and the + stored wall would name columns that module does not have — which `permits()` resolves by + DENYING every row. No error at any point. So the map is explicit and an unknown key RAISES: + a topic without a schema arm is a missing arm, never a customer grid in disguise. + + ⭐⭐ W36-T22 / R6 — AND `ut_*` NOW HAS AN ARM, WHICH IS THE HALF THAT MAKES THE TOGGLE REAL. + Owner item 11: *"how come the database is only toggleable for Odoo customers and Odoo + products … EVERY database should be able to be toggleable by admin."* A `ut_*` database's + field contract is its OWN stored definition, so the picker offers exactly the columns that + database has. ⛔ ONE SOURCE, NOT TWO: the picker (`get_perms.fields_by_module`) and the + validator (`_clean_perms`) both call this, so a column the picker offers can never be a + column the validator rejects. Getting that wrong is worse than an error message — + `permits()` DENIES on a predicate it cannot answer, so a mismatched wall is a + deny-everything trap wearing a saved-successfully toast. + + ⚠ `session` IS REQUIRED FOR A `ut_*` KEY and unused for a topic: a stored definition is + per-tenant data and there is no tenant-blind way to read one. A caller that omits it is + refused LOUDLY rather than handed another tenant's schema — the same rule the `else` + fall-through above was deleted for. + """ + import aios_grid + key = str(key or "") + if key in {s["key"] for s in perms.governable_surface_modules()}: + # ⭐ A SURFACE (Assistant, Agents) has NO field schema, and `[]` is the honest + # answer rather than the wave-26 empty-option trap: the editor's schemaless rendering + # is a deliberate state (access toggle only, R9), and `_clean_perms` refuses any + # filter or hiddenFields validated against this empty vocabulary. An unknown key + # still RAISES below — this arm admits exactly the derived surface list. + return [] + if key.startswith("ut_"): + if session is None: + raise err(400, "ungoverned_module", + f"{key!r} is a tenant database, so its columns can only be read for a " + f"known tenant — this caller passed no session") + import core.user_tables as user_tables + defn = user_tables.get(key, st=user_tables.lend_defs(session.runtime)) + if not defn: + raise err(400, "ungoverned_module", + f"{key!r} is not a database in this workspace") + src = defn.get("fields") or [] + else: + provider = _static_field_providers().get(key) + if provider is None: + # Reached only by a caller that skipped `_perm_modules`. Loud, because the alternative + # (`[]`) is an empty option list read as an ANSWER — wave 26 item 24, `if ([])` is + # truthy. + raise err(400, "ungoverned_module", + f"{key!r} has no permission-editor field schema. The editor governs " + f"{sorted(providers)} and this tenant's own databases") + src = list(provider()) + import core.perm_scope as perm_scope + # ⭐⭐ W40-T05 / OWNER I16 — THE USER-GENERATED COLUMNS JOIN THE TOPIC'S VOCABULARY. + # *"Permission Filters must be able to filter on user-generated Fields too."* The two topic + # providers above are the STATIC contract (`aios_grid.FIELDS` = 35, `product_fields()` = 40 + # including the built-in Image column), so until this line NO column a user created could + # ever reach the picker — measured, both topics, zero `custom_` keys in either. The `ut_*` + # arm never needed it: a user table's stored definition IS its user-generated schema. + # + # ⛔ ADDITIVE AND DEDUPED, so a declared column always wins its own key — the same rule the + # metric rows are appended under below. A user column shadowing a contract column would let + # an admin hide or filter something other than what they read off the label. + # + # ⚠ NO SESSION MEANS TODAY'S ANSWER, AND THAT IS CORRECT RATHER THAN DEGRADED. A workspace + # bucket is per-tenant data with no tenant-blind read, and two callers arrive without one: + # `_fold_legacy_scope` on the live PUT path and `verify_api`'s W30a probe. Both get the + # pre-set list they have always had, and the legacy fold cannot start dropping a leaf it used + # to keep, because `_prune_to_module` only ever narrows the LEGACY tree (`dba`/`agent`) to + # keys the contract already carries. + if key in _FIELD_PROVIDER_KEYS: + _have = {f.get("key") for f in src if isinstance(f, dict)} + for _uf in (perm_scope.user_generated_fields( + key, st=getattr(session, "runtime", None)) or ()): + if isinstance(_uf, dict) and _uf.get("key") and _uf["key"] not in _have: + _have.add(_uf["key"]) + src.append(_uf) + known = _server_side_vocabularies() + out = [] + for f in src: + if not isinstance(f, dict) or not f.get("key"): + continue + opts = f.get("options") + if not isinstance(opts, list) or not opts: + # ⭐ D-76 / W33-T35 — SUPPLY WHAT THE SERVER ALREADY KNOWS. See + # `_server_side_vocabularies`. + opts = known.get(f["key"]) + out.append({"key": f["key"], "label": f.get("label") or f["key"], + "type": f.get("type") or "text", + **({"options": list(opts)} if isinstance(opts, list) and opts else {}), + **({"pinned": True} if f.get("pinned") else {}), + # ⭐⭐ CONTRACT C3 / AM-2 — THE FOUR MEMBERSHIP BOOLEANS, ON EVERY ROW. + # READ OFF THE FIELD'S OWN DECLARATION, never inferred from its key, because + # the declaration is the authority the GRID already uses: + # `aios_grid.clean_measure_field` writes `custom: True` on a user-created + # column, the product contract declares `shared: true` on its nine + # tenant-wide-value columns, and `filterable: False` is how a column states + # the engine cannot filter it (`est_missed` is the one that does). + # + # ⚠ `shared` IS INFORMATIONAL AND MUST NEVER BECOME THE HIDE RULE. AM-2's + # rule is `metric || !custom`. Nine declared PRODUCT columns carry + # `shared: true` (`first_cost`, `supplier`, `notes`, ...) and every one of + # them has to stay hideable — `verify_api`'s legacy-fold leg hides + # `first_cost` by name. A client that excluded rows on `shared` would revoke + # the admin's control over nine pre-set columns, which is I15's own defect + # one word over. + "custom": bool(f.get("custom")), + "shared": (bool(f.get("shared")) + or f.get(perm_scope.FIELD_GRANT_MARK) is True), + "filterable": f.get("filterable") is not False, + "metric": False}) + # ⭐⭐ I13 — THE METRIC ROWS, APPENDED LAST so no declared column is ever displaced, and + # skipped on a key collision so a declared column always wins its own key. + taken = {f["key"] for f in out} + out.extend(m for m in _metric_fields(key) if m["key"] not in taken) + return out + + +def _metric_fields(key): + """One `measure_`-namespaced pseudo-field per measure BOUND to `key`'s entity topic. + + ⭐⭐ I13 — THE ROW THE OWNER ASKED FOR: *"Fold 'Metrics fields (lookback measures over + this database)' into the 'Hide fields' checkboxes, one row per metric, named + 'Metric - Revenue', 'Metric - Order' and so on, so a user can check the ones the + permissioning is limited to."* Until this existed the entire capability was ONE boolean + (`perm_scope.may_metrics`), so an administrator could revoke every metric on a database or + none of them. + + ⭐⭐ AND IT IS WHY I15 HAD NO CONTROL TO OFFER. *"Shantal has access to gross profit."* + "Gross profit" is the measure `margin` — label "Gross margin $", defined at + `platform/model/metrics/sales.yml::margin` and bound onto this grid by + `platform/model/topics/odoo_products.yml::measures`. **It is not a column in + `aios_grid_fields.json` and never was**, so a picker built from the declared contract alone + could not list it however carefully that contract was read. No key literally named + `gross_profit` reaches this grid at all. + + ⛔ THE OFFER IS READ, NEVER RESTATED. `semantic.entity_measures` is the ONE catalogue, and + it REFUSES a key it cannot prove — a half-synced mirror, a metric with no `empty:` family, + a format with no grid type — with the cause available through `entity_measure_refusals`. + Re-measured 2026-08-24 in this tree, after W40-T10 bound the channel split: the product topic + declares **27 key-slots across five bindings** and the catalogue offers **24**; + `stock_in`/`stock_out`/`stock_net` are refused because `stock_move` has not finished syncing. + Hardcoding a declared list would offer three columns that render blank today AND would stop + growing the day a binding is added. The offer widens on its own when the mirror catches up, + because this asks rather than remembers — and the eighteen channel rows below + ("Metric - Revenue (Fisch)", "Metric - Revenue (Amazon)", …) arrived here with NO change to + this function for exactly that reason. + + ⭐⭐ W40-T10 / owner item 26 (R7) — AND THAT IS WHY THE CHANNEL RIDES THE MEASURE KEY. A + per-metric channel spelled as a member of the stored field spec would be invisible here: + this row is minted from `m["key"]` and `m["label"]`, so a channel carried anywhere else + would give an admin eighteen columns they can neither tell apart nor hide separately. + `semantic._one_binding_offer` therefore namespaces the key and appends "(Fisch)" to the + label, and both halves land in this vocabulary for free. + + ⛔ THE TOPIC IS DERIVED, NEVER MAPPED. `semantic.topic_for_grid` reads the `grid:` key the + topic file already declares, which is the reason its own docstring gives for existing: a + `{module: topic}` literal in a route would be a second statement of one fact and would + drift the day a topic is renamed. It answers for every arm — `product_data` -> + `odoo_products`, `customer_data` -> `odoo_customers` (which binds none), `ut_odoo_agents` -> + `odoo_agents` (which binds seven) — so this needs NO per-module branch, and a database + with no bound measure simply receives no rows. + + ⛔⛔ IT CANNOT RAISE, AND THAT IS LOAD-BEARING RATHER THAN DEFENSIVE. Two callers reach + `_module_fields` with NO session and outside any store fixture: `_fold_legacy_scope`, on the + live migration path of a PUT, and `verify_api`'s W30a probe, which runs ABOVE the line that + installs the fake store. A raise on either is not a red gate but a CRASHED one + [[gate-must-go-red-not-crash]], and on the route it is a 500 on the administrator's own save. + `entity_measures` already degrades to `[]` when the mirror cannot be read; this makes the + whole derivation degrade with it, so the payload simply carries no metric rows. + + ⛔ `filterable: False`, AND IT IS A MEASUREMENT RATHER THAN A PREFERENCE. A pseudo-key is + not a column any row carries — a real Metric column is keyed `measure__` by + `CustomerGrid.createField`. Measured: `clean_filter_tree` ACCEPTS a leaf on `measure_margin` + once the key is admitted, and `harness.filter_eval.permits` then answers **False for every + product row**, because the row has no such key. That is exactly the "deny-everything trap + wearing a saved-successfully toast" `_module_fields`' own docstring forbids. The governed way + to filter on a measure is the measure CONDITION channel (`measure_sets`, CG-8), not a field + leaf, so these rows join the HIDE vocabulary only. + + ⛔ THE PREFIX IS `aios_grid.MEASURE_FIELD_PREFIX`, IMPORTED. Spelling "measure_" here would + be a second copy of a constant two features already share + [[constant-two-features-share]], and `verify_fields_contract` gates that the client keys its + real measure columns off the same one. + """ + try: + import aios_grid + import harness.semantic as semantic + topic = semantic.topic_for_grid(key) + if not topic: + return [] + offer = semantic.entity_measures(topic) + except Exception: # noqa: BLE001 + return [] + out = [] + for m in offer or (): + if not isinstance(m, dict): + continue + mkey = str(m.get("key") or "").strip() + if not mkey: + continue + # ⭐ THE LABEL IS THE MEASURE'S OWN, prefixed. A hand-written second vocabulary here + # would read "Metric - Gross profit" while the grid column read "Gross margin $", and an + # admin cannot hide what they cannot recognise. ASCII hyphen, exactly as the owner wrote + # it — CLAUDE.md rule 2 bars a dash in copy that reaches a screen. + label = str(m.get("label") or mkey).strip() + mtype = m.get("type") + out.append({"key": f"{aios_grid.MEASURE_FIELD_PREFIX}{mkey}", + "label": f"Metric - {label}", + "type": mtype if mtype in aios_grid.MEASURE_FIELD_TYPES else "currency", + "custom": False, + "shared": False, + "filterable": False, + "metric": True}) + return out + + +#: The value every blank Odoo connector attribute displays as. The writer is +#: `modules/customers._partner_attrs` (`... or '(none)'`) and the client mirror is +#: `customer-grid/types.ts::CONNECTOR_BLANK`. It is a DELIBERATE, documented, visible value, so a +#: choice vocabulary that omitted it would deny an admin any rule about the blank rows. +BLANK = "(none)" + +#: `_server_side_vocabularies`' per-process memo. `None` until a COMPLETE build succeeds; a build +#: with any failed arm is returned but NOT stored, so a transient Odoo outage cannot pin a short +#: answer for the life of the worker. +_VOCAB_CACHE = None + + +def _server_side_vocabularies(): + """`{field key: [choice, …]}` for choice columns whose vocabulary is CLOSED and known here, + but which the grid contract does not declare. + + ⛔ D-76 — WHY THE PERMISSION EDITOR'S DROPDOWNS WERE EMPTY, AND WHY THE FIX IS HERE. The grid + resolves a choice column through `types.ts::choiceVocabulary`: the DECLARED list when the + field has one, otherwise the values seen in the loaded ROWS. This editor loads no rows, so + every column that leans on the second branch renders `Select…` with nothing in it — the + identical shape wave 26 item 24 closed on the Product grid, one surface over. ⚠ And the fix + belongs at the CALLER, never in the panel: `FilterBuilderPanel::choicesFor` treats a supplied + list as authoritative *including when it is empty* ("this column has no values" is an answer), + which is correct and must not be weakened. So the caller stops handing it nothing. + + ⭐ IMPORTED, NEVER RESTATED. `stock_bucket`'s labels are `modules/inventory.COVERAGE_LABELS`, + the same constant `_bucket()` mints from — a copy here would be a second vocabulary that + drifts the first time a band is renamed [[constant-two-features-share]]. The import is lazy + for the same reason `aios_grid`'s is: this route should not pull the analytics stack at + module load. + + ⭐⭐ W40-T06 / OWNER I11+I23 — NINE MORE CHOICE COLUMNS, AND WHY THE OLD REFUSAL EXPIRED. + Ruling R5 retyped ten Odoo customer columns off `text`, so nine of them now reach the branch + above and D-236 (`category` renders an EMPTY dropdown here) would have been multiplied by + nine rather than fixed. `category` was refused in this docstring because "discovering it + would mean a pool read on an admin route". That reason was WRONG about the cost, not about + the principle: none of the reads below is a pool read. Each is either a `read_group` over + `res.partner` (one grouped row per distinct value) or a whole small master table, and the + dearest of them was MEASURED at 1.8s cold, once per process. + + ⛔⛔ A VOCABULARY MUST MATCH THE CELL, NOT THE RAW SOURCE, and the pricelist is the proof. + `modules/customers._partner_attrs` writes `O.m2o_name(property_product_pricelist)`, which is + Odoo's DISPLAY name: the cell reads "Fisch (USD)". `product.pricelist.name` reads "Fisch". + A vocabulary built from `name` would therefore offer five options, and every one of them + would match ZERO rows. So every arm below reproduces the writer's own expression: + `display_name` for an m2o, `.strip().title()` for `city`, the `', '.join(...)` for the m2m + `agent`, and `'(none)'` wherever `_partner_attrs` collapses a blank (the set of fields that + do is documented in the contract JSON's own `_comment`). + MEASURED against real cells over all 5,282 partners, 2026-08-23: country 20 offered / 20 + observed, state 64 / 64, agent 14 / 13, city 1,254 / 1,245, pricelist 5 / 4, + payment_terms 15 / 10, tags 6 / 5. ⚠ Read the direction: NOT ONE observed cell value is + missing from what is offered. The overshoot is partners outside the grid's own 24 month + population, and it is the SAFE direction. A missing option is the dangerous one, because it + silently denies the admin any rule about those rows. + + ⚠ `tags` IS A MULTISELECT, so it is offered as MEMBERS, not as joined cells. + `choiceVocabulary` splits a multiselect cell on commas, so "Fisch, Royal" must arrive as + "Fisch" and "Royal". `agent` stays a SINGLE select per R5 and therefore keeps its joined + combinations. ⛔ NOTE FOR THE OWNER, not acted on here: `agent` is comma joined multi value + exactly as `tags` is (13 observed cells are combinations such as "Avi Ash, Martin + Pasternak"), so R5's own argument for making Tags a multiselect applies to it too. R5 rules + single select, so single select is what this builds. + + ⛔ DEGRADE BY OMITTING THE KEY, NEVER BY RETURNING `[]`. `FilterBuilderPanel::choicesFor` + treats a supplied list as authoritative INCLUDING when it is empty, so an empty list asserts + "this column has no values". Every arm is therefore isolated in its own try/except and a + failed read leaves its key OUT, exactly as the original single-arm version did. + + ⚠ CACHED PER PROCESS, because `_module_fields` runs on every admin GET and on every PUT via + `_fold_legacy_scope`. Only SUCCESSES are cached, so a transient Odoo outage cannot pin an + empty answer for the life of the worker. STALE WHEN: someone adds or renames a pricelist, + payment term, partner tag or product category; a customer moves city, state or country; an + agent is assigned or unassigned; a new salesperson takes their first order. None of those is + observable from here, and the refresh is a process restart, which a deploy already does. + + ⚠ ONE COLUMN IS STILL DELIBERATELY ABSENT, and R6's second sentence says to name it rather + than let it look handled: + · `odoo_status` (customer) — WAS bare and is NOT any more: lane E answered `ASK D-15` by + declaring `options: ['Active','Archived']` on the CONTRACT, which is the right home (the + grid picks a declared list up for free) and is why this function never needed an arm for + it. Recorded because the ask, not a guess, is what settled it. + """ + global _VOCAB_CACHE + if _VOCAB_CACHE is not None: + return dict(_VOCAB_CACHE) + + out = {} + + def arm(fn): + """Run one source. A failure omits its keys and never touches the others.""" + try: + for key, values in (fn() or {}).items(): + clean = sorted({str(v) for v in values if str(v or "").strip()}) + if clean: + out[key] = clean + return True + except Exception: # noqa: BLE001 + return False + + def _stock_bucket(): + # IMPORTED, NEVER RESTATED: the same constant `_bucket()` mints from, so a renamed band + # cannot leave a second vocabulary behind [[constant-two-features-share]]. Lazy for the + # same reason `aios_grid`'s import is: no analytics stack at module load. + import modules.inventory as inventory + return {"stock_bucket": list(inventory.COVERAGE_LABELS or ())} + + def _categories(): + import modules.inventory as inventory + # W40-T06: the root maps to None now, so "All" cannot reach either list. The sentinel is + # what `products.catalogue` and `sales._product_cat` substitute for that None. + cats = sorted({v for v in inventory._cat_main_map().values() if v}) + return {"category": cats + ["(uncategorized)"], + "top_category": cats + ["(uncategorized)", BLANK]} + + def _suppliers(): + """The product `supplier` column's observed values. + + ⛔⛔ THIS ARM EXISTS BECAUSE W40-T06 CREATED THE HOLE IT FILLS. `supplier` was `text` + until I18 made it a multiselect, and a choice column with no declared options and no + entry here is precisely D-236's empty dropdown. The flip would otherwise have ADDED a + tenth blank picker while this ticket was removing nine. + + ⛔⛔ DECISION OWED, AND IT IS VISIBLE ON SCREEN: 18 of the 88 distinct suppliers carry a + COMMA INSIDE THE COMPANY NAME ("CANDLE ARTISANS, INC", "BAOBEI INT'L CO.,LTD"), spanning + 371 product codes. A multiselect cell is by contract a comma-joined SET, and + `types.ts::choiceVocabulary` SPLITS it, so the grid's own row-derived picker will offer + 26 fragments ("CANDLE ARTISANS", "INC") where 18 real suppliers should be. That is a + data fact about supplier names, not a bug in the splitter: unlike `tags`, `supplier` is + a SINGLE value that merely contains punctuation. This function serves the WHOLE values, + which is what the cells actually hold and what an admin means to filter on. The kind + itself is the owner's to settle (`select` would make the collision disappear); I18 says + multi-select, so multi-select is what the contract carries. + + ⚠ SEED VALUES, NOT LIVE CELLS. `supplier` is an OVERLAY column a user may retype per + record, and those edits live in the tenant's workspace, which this route cannot read + tenant-blind. So a supplier somebody typed by hand is missing from this list until the + mastersheet carries it. Cheap and safe: `supplier_master()` is a local file cached per + process, and it degrades to `{}` rather than raising. + """ + import modules.product_data as product_data + return {"supplier": [(v.get("supplier") or "").strip() + for v in (product_data.supplier_master() or {}).values()]} + + def _masters(): + import core.odoo as O + got = {} + for key, model in (("pricelist", "product.pricelist"), + ("payment_terms", "account.payment.term"), + ("tags", "res.partner.category")): + # display_name, never name: see the pricelist proof in the docstring. + got[key] = [c["display_name"] for c in O.search_read(model, [], ["display_name"])] + if key != "pricelist": # measured: every customer carries a pricelist + got[key] = got[key] + [BLANK] + return got + + def _partner_dims(): + import core.odoo as O + rg = lambda field: O.read_group("res.partner", [], ["id"], [field], lazy=False) + return { + "country": [O.m2o_name(r.get("country_id")) or BLANK for r in rg("country_id")], + "state": [O.m2o_name(r.get("state_id")) or BLANK for r in rg("state_id")], + # `.strip().title()` is `_partner_attrs`' own expression. Raw mirror values would + # offer "NEW YORK" where the cell says "New York": no match, in both directions. + "city": [(r.get("city") or "").strip().title() or BLANK for r in rg("city")], + } + + def _agents(): + import core.odoo as O + rows = O.search_read("res.partner", [], ["agent_ids"]) + ids = {i for r in rows for i in (r.get("agent_ids") or [])} + names = {p["id"]: p["name"] + for p in O.search_read("res.partner", [("id", "in", list(ids))], ["name"])} + return {"agent": [", ".join(n for n in (names.get(i) for i in (r.get("agent_ids") or [])) + if n) or BLANK for r in rows]} + + def _salespeople(): + # The ORDER TAKER over LTM (`sale.order.user_id`), which is what the column holds, NOT + # `res.users`: `modules/customer_data._salesperson_attrs` is the writer, and it reads the + # order's taker. Grouped, so the result is one row per distinct person. + import core.odoo as O + import core.periods as P + import modules.sales as S + mf, mt = P.ltm(P.today()) + rows = O.read_group("sale.order", S.order_domain(str(mf), str(mt), None), + ["amount_untaxed:sum"], ["user_id"], lazy=False) + return {"salesperson": [O.m2o_name(r.get("user_id")) or BLANK for r in rows]} + + complete = all([arm(source) for source in + (_stock_bucket, _categories, _suppliers, _masters, _partner_dims, + _agents, _salespeople)]) + if complete: + _VOCAB_CACHE = dict(out) + return out + + +def _prunable_vocabulary(key, session=None, fields=None): + """The field keys `key` HAS right now — or `None` when that is not trustworthy enough to + prune a stored permission filter against. + + ⛔⛔ THE RETURN IS A SAFETY DEVICE, NOT A CONVENIENCE. The cascade below DELETES a filter leaf + naming a key that is not in this set, so a set that is short by one column revokes a live + permission rule — permanently, silently, and in the WIDENING direction. Three separate ways + to be short are refused here rather than papered over: + + * a TOPIC whose workspace bucket could not be read (`user_generated_fields` answers `None`, + which is exactly why it distinguishes that from `[]`). This is the case that would fire + under ordinary store contention, and it is the dangerous one: measured in this tree, a + busy DuckDB already takes `_module_fields('product_data')` from 45 rows to 39. + * a SURFACE (Assistant, Agents), whose vocabulary is `[]` by design. Pruning against an + empty set would drop every leaf of anything stored there. + * any key whose schema read RAISES — an unknown module, a `ut_*` database whose definition + the store cannot serve. + + ⚠ `fields` is threaded so the ONE `_module_fields` call a caller has already paid for is + reused. Recomputing it would double a read that costs a store round-trip per module on a + route that already makes one per governed database. + """ + import core.perm_scope as perm_scope + + key = str(key or "") + if key in _FIELD_PROVIDER_KEYS: + if perm_scope.user_generated_fields(key, st=getattr(session, "runtime", None)) is None: + return None + elif not key.startswith("ut_"): + return None + if fields is None: + try: + fields = _module_fields(key, session=session) + except Exception: # noqa: BLE001 + return None + return {f["key"] for f in fields if isinstance(f, dict) and f.get("key")} or None + + +def _prune_stale_filters(block, session=None): + """`(block, {module: [dropped keys]})` — owner I16's cascade over a whole stored perms block. + + ⭐⭐ *"If the field is deleted, its permission filter goes with it."* Run at the ADMIN DOOR, + on the record, never in the wall: `perm_scope.prune_filter_to_fields`' own docstring carries + the reason (`verify_perm_scope`'s "a wall naming a DELETED column denies every row" leg must + stay green, and it asserts on `apply_row_scope` directly). + + ⚠ NON-DESTRUCTIVE and entry-wise: an entry with no filter, an entry whose vocabulary will not + resolve, and an entry with nothing stale all come back as the object that went in. Only an + entry that actually lost a leaf is rebuilt, so a caller can use the returned map as its + "is a write needed" test rather than comparing documents. + """ + import core.perm_scope as perm_scope + + out, dropped = dict(block or {}), {} + for key, entry in (block or {}).items(): + if not isinstance(entry, dict) or not entry.get("filter"): + continue + vocab = _prunable_vocabulary(key, session=session) + if vocab is None: + continue + tree, gone = perm_scope.prune_filter_to_fields(entry["filter"], vocab) + if gone: + out[key] = dict(entry, filter=tree) + dropped[key] = gone + return out, dropped + + +def _static_field_providers(): + """The STATIC contract behind each topic grid, read by TWO callers. + + `_module_fields` builds the picker from it and then APPENDS the user-generated columns; + `_row_wall_blind_keys` needs the same set to tell those two apart. A second copy of this map + would let them disagree about which columns are the database's own, and the disagreement + would show up as a permission filter refused on a column that was never user-generated. + """ + import aios_grid + + return {"customer_data": lambda: aios_grid.FIELDS, + "product_data": aios_grid.product_fields} + + +def _leaf_col_ids(nodes): + """Every `colId` a filter tree names, at any depth. Groups carry `children`; leaves do not.""" + found = set() + for node in nodes or (): + if not isinstance(node, dict): + continue + if isinstance(node.get("children"), list): + found |= _leaf_col_ids(node["children"]) + elif node.get("colId"): + found.add(str(node["colId"])) + return found + + +def _row_wall_blind_keys(key, session=None): + """User-generated columns of a TOPIC grid that the row wall STILL cannot answer. + + THE DEFECT THIS REFUSES. W40-T05 taught `_module_fields` to append + `perm_scope.user_generated_fields`, so the permission editor now OFFERS a column a user made + (owner instruction 16), and this validator accepted a wall naming it. But on the two topic + grids the wall ran as `apply_row_scope(rows, user, MODULE, )`, over + PRE-OVERLAY rows: the column was neither declared in that field list nor present on the row, + and `filter_eval.permits` DENIES every leaf it cannot answer. + + Measured on the integrated head, one leaf {colId: fld_region, op: eq, value: West}, two rows: + + static contract + pre-overlay rows (THE OLD CALL SITE) -> [] every row denied + column declared + rows carrying the value -> ['1'] correct + column declared + pre-overlay rows -> [] + a DECLARED odoo column, same shape -> ['1'] the evaluator is fine + + So an administrator saved a rule, was told it saved, and that account opened an empty grid + with nothing on screen saying why. Fail-CLOSED, and one click undid it, but silent. + + ⭐⭐ AND THE PROPER FIX IS NOW DONE, SO THIS SET NARROWS RATHER THAN COVERING EVERY USER + COLUMN. `perm_scope.apply_row_scope` takes a tenant handle and every door that walls these + two modules passes it (`routes_customers` twice, `routes_products`, `routes_slack`, + `deps`, and `perm_scope._scoped`); with it, a TENANT-WIDE overlay column is merged onto the + rows and declared to the evaluator before `permits` runs. What is left blind is what stays + genuinely unanswerable HERE, and it is `perm_scope.wallable_overlay_keys` that decides, + never a list restated in this file — one evaluator, so the write door and the wall cannot + disagree about which columns are filterable: + + * a `formula` or `created_time` column, whose value is computed in the BROWSER over the + rendered row and never sits on a stored one; + * a `measure_*` column, computed from the measure catalogue per render; + * a PER-USER private overlay column, whose values live in the subject's own stratum — an + account may edit those freely, so a wall over one is a wall its subject can walk out of, + and the admin who wrote the rule cannot see the values at all. + + REFUSED AT THE WRITE, WHERE A PERSON IS PRESENT TO FIX IT, which is the same call + `_refuse_unshapeable_bu` makes further down this file for the same reason. + + NARROW BY CONSTRUCTION, three ways. Only the two TOPIC grids: a `ut_*` database enforces + correctly today, because its stored definition IS its user-generated schema and its rows carry + the values. Only the FILTER: `hiddenFields` still accepts these columns, because hiding is + applied to the assembled field list, which does have them. And only columns the static + contract does NOT declare, so a user column shadowing a real one cannot make the real one + unfilterable. + + `user_generated_fields` answers `None` when the vocabulary cannot be read, and `None` is + handled by NOT narrowing, which is correct rather than lax: on that same answer + `_module_fields` appended nothing, so there is nothing in `filter_keys` to take out. + + ⚠ AND THE TWO DEGRADED READS FAIL CLOSED THROUGH DIFFERENT DOORS, WHICH IS WORTH TRACING + RATHER THAN ASSUMING. If the whole vocabulary is unreadable, `user_generated_fields` answers + `None` and this set is EMPTY — it refuses nothing, because there is nothing here to refuse: + `_module_fields` appended nothing on that same answer, so the column is not in `filter_keys` + either and the generic `clean_filter_tree` leaf-count check 400s it one branch down (with + the "unknown field" wording, which is the honest thing to say when the server cannot see the + column at all). If instead the vocabulary resolves and only `wallable_overlay_keys` degrades + to `{}`, this set becomes EVERY user column — the widest refusal, and the pre-change + behaviour. Neither path can accept a wall the enforcement layer will be unable to apply. + """ + if key not in _FIELD_PROVIDER_KEYS: + return set() + import core.perm_scope as perm_scope + + st = getattr(session, "runtime", None) + provider = _static_field_providers().get(key) + declared = {f.get("key") for f in (provider() if provider else ()) + if isinstance(f, dict)} + found = perm_scope.user_generated_fields(key, st=st) + wallable = set(perm_scope.wallable_overlay_keys(key, st=st)) + return {f["key"] for f in (found or ()) + if isinstance(f, dict) and f.get("key") and f["key"] not in declared + and f["key"] not in wallable} + + +def _clean_perms(v, governed_keys=None, session=None): + """Validate a whole `perms` block. FAIL-CLOSED, and LOUD rather than lenient. + + ⭐ WAVE 33 (W33-T33) — `governed_keys` IS PER TENANT AND IT IS NOT OPTIONAL IN PRACTICE. It + used to be `set(_PERM_MODULES)`, a literal, which is the same tenant-blindness owner item 11 + flagged: an admin of a tenant provisioned for neither grid module could still store a wall + for both. It now comes from `_perm_modules(session)`, i.e. from the tenant's own catalogue. + The default is the topic catalogue itself so a test or a future caller cannot silently widen + it. + + ⭐⭐ W36-T22 / CONTRACT C2 — **THE `enforced`/`listed` SPLIT IS GONE, AND SO IS + `unenforced_module`.** This function took TWO key sets and refused anything in the gap + between them, with a message that ended *"Share the database instead, or arm `perm_scope` + over `ut_*` first."* W36-T21 armed it. There is no gap left: every database the editor lists + is a database whose wall `routes_tables` applies on every read, so a wall stored against one + is enforced by the same code that enforces `customer_data`'s. **Two sets collapse into one**, + which is the shape C2 asks for — not a second set that is always equal to the first. + + ⚠ `session` IS THREADED SO `_module_fields` CAN READ A `ut_*` SCHEMA. Without it the + validator would have no field contract for the databases this ticket just made governable, + and "no contract" resolves to "every `hiddenFields` entry is unknown" — a 400 on the admin's + own screen. + + ⛔ WHY THIS REFUSES WHERE `clean_filter_tree` DROPS. The view sanitiser is deliberately + drop-per-node: one bad rule must never cost a user their whole saved view. A PERMISSION + filter is the opposite situation — an admin types "agent is Tara", a leaf is silently + dropped, and the stored wall is EMPTY. The admin sees "Saved", the account sees the whole + book, and nothing anywhere says so. So anything that would be dropped is a 400 instead: + the filter is re-validated with `clean_filter_tree` and the result must come back with the + SAME leaf count it went in with. + + Shape (C-PERM amendment 2 — a `FilterTree` is a PAIR, `{conj?, nodes}`; `clean_filter_tree` + validates the NODE LIST alone and never sees the root conjunction, so the two are checked + separately and a bare node list is refused rather than silently read as `and`). + """ + import aios_grid + + if v is None: + return None + if not isinstance(v, dict): + raise err(400, "bad_perms", "perms must be an object keyed by module") + governed = set(governed_keys) if governed_keys is not None else set(_PERM_MODULES) + unknown = sorted(set(v) - governed) + if unknown: + raise err(400, "bad_perms", + f"unknown module keys: {unknown} — this tenant's permission editor governs " + f"{sorted(governed)}") + out = {} + for key, raw in v.items(): + if not isinstance(raw, dict): + raise err(400, "bad_perms", f"{key}: each entry must be an object") + # ⭐⭐ AM-2 — ONE SOURCE, TWO VOCABULARIES, AND THE VALIDATOR READS BOTH. The + # picker and this validator still call the SAME function, which is the property + # `_module_fields`' docstring exists to protect; what changed is that a field now states + # which vocabulary it belongs to, and each consumer reads its own membership instead of + # the whole list. + # + # ⛔ THE SPLIT IS WHY THE METRIC ROWS ARE SAFE TO ADD. `hiddenFields` is validated + # against EVERY key (a metric row is hideable — that is I13's whole point), while the + # permanent FILTER is validated against `filterable` only. Without the second set, + # admitting `measure_margin` would make {"colId": "measure_margin", ...} a storable + # permanent wall, and `permits()` answers False for every row that has no such key: + # measured, not feared. `clean_filter_tree` takes bare strings and cannot see the flag, + # so the narrowing has to happen HERE, at the call. + fields_here = _module_fields(key, session=session) + valid_keys = {f["key"] for f in fields_here} + filter_keys = {f["key"] for f in fields_here if f.get("filterable")} + # W40-T05 x W40-T18, see `_row_wall_blind_keys`. A user-generated column of a TOPIC grid + # is always HIDEABLE (it stays in `valid_keys`), and it is FILTERABLE only when the wall + # can actually answer it — a tenant-wide overlay column can, a browser-computed one and a + # per-user private one cannot, and those keep denying every leaf they are named in. + row_wall_blind = _row_wall_blind_keys(key, session) + filter_keys -= row_wall_blind + hidden = raw.get("hiddenFields") or [] + if not isinstance(hidden, list): + raise err(400, "bad_perms", f"{key}: hiddenFields must be a list") + submitted = {str(h) for h in hidden} + # ⛔⛔ A METRIC TICK IS ADMITTED ON ITS PREFIX, NOT ON THE LIVE OFFER, AND THAT CLOSES A + # GET/PUT SKEW RATHER THAN WEAKENING THE GUARD. + # + # `_metric_fields` asks `semantic.entity_measures`, which REFUSES every key while the + # mirror cannot be read — measured twice in this tree on 2026-08-23, when another + # process held the DuckDB and the offer came back empty. That answer is deliberately not + # cached (`_ENTITY_OFFER_CACHE` skips an indeterminate one), so the state recurs freely. + # Without this line the sequence is: the editor GETs a payload carrying + # `measure_margin`, the admin ticks "Metric - Gross margin $", the PUT lands a moment + # later while the store is busy, `valid_keys` has 39 entries instead of 45, and the + # administrator is told their own tick *"names unknown fields"*. That is this function's + # ONE SOURCE invariant running backwards — the validator rejecting exactly what the + # picker offered. + # + # ⭐ AND ADMITTING IT IS CORRECT, not merely convenient, because the ENFORCEMENT keys off + # the measure NAME and not off the offer: `perm_scope._measure_bound_keys` hides every + # column whose `measure.key` matches, whether or not the catalogue is answering right now. + # So a tick stored during a warm-up walls the right columns the moment they render. A + # metric key that never comes back hides nothing, which is the harmless direction; the + # refusal is the harmful one. + # + # ⚠ NARROW BY CONSTRUCTION: only the `measure_` namespace is admitted this way, and only + # with a non-empty suffix. Every other unknown key is still refused, because a + # `hiddenFields` entry naming nothing reads as a restriction that is not there. + _mpre = aios_grid.MEASURE_FIELD_PREFIX + metric_ticks = {h for h in submitted + if h.startswith(_mpre) and len(h) > len(_mpre)} + bad = sorted(submitted - valid_keys - metric_ticks) + if bad: + # A hiddenFields entry naming nothing hides nothing — and reads as a restriction + # that is not there. + raise err(400, "bad_perms", + f"{key}: hiddenFields names unknown fields {bad}") + tree = raw.get("filter") + clean_tree = None + if tree not in (None, {}, []): + if not isinstance(tree, dict) or not isinstance(tree.get("nodes"), list): + raise err(400, "bad_filter", + f"{key}: filter must be {{conj?, nodes:[…]}} — a bare list would lose " + f"the root conjunction, and an 'or' wall read as 'and' restricts " + f"nothing it was meant to") + conj = tree.get("conj", "and") + if conj not in ("and", "or"): + raise err(400, "bad_filter", f"{key}: conj must be 'and' or 'or'") + nodes = tree["nodes"] + # ⛔⛔ THE CASCADE DOES **NOT** RUN ON THE SUBMITTED TREE, AND W40-T05 SHIPPED THE + # OPPOSITE FOR AN HOUR. It pruned here first, so that an administrator with a stale + # tab would not be told their own saved rule was a client error. Sympathetic, and + # WRONG: `verify_api`'s leg *"⛔ a filter naming an unknown FIELD -> 400, never + # saved-minus-the-bad-leaf"* went red on the very case it exists for — a PUT naming + # `ghost` was accepted and stored minus that leaf. This validator is a SECURITY door, + # and the sentence the refusal below already carried says why: a wall saved with its + # bad conditions silently removed is WEAKER THAN THE ONE ON SCREEN. + # + # ⭐ SO THE CASCADE LIVES ON THE **STORED** RECORD ONLY, in `get_perms` (see + # `_prunable_vocabulary`'s own docstring). That is the honest split, and it is enough + # for I16: the leaf disappears when the record is next read, which is the same page + # load a stale tab has to do anyway. A leaf in a payload the client just SENT is + # refused, loudly, whatever its history — the server cannot tell a deleted column from + # a typo in a submission, and only one of those two guesses is safe. + # ⛔ AN EMPTIED FILTER IS `None`, NEVER `{"conj": "and", "nodes": []}`. Measured: + # `filter_eval.permits` returns True on an empty node list, so an empty-but-present + # tree admits every row — while `perm_scope.wall_declared` and `row_scope_applies` + # both read it as TRUTHY and answer that a wall applies. A door would then build rows + # in order to filter them against nothing, and the editor would paint a rule that is + # not there. This also normalises a client that PUTs `{"nodes": []}` to mean "no + # filter", which is what it has always meant on screen. + if nodes: + # Named before the generic refusal below, because "an unknown field" is exactly + # what this is NOT: the picker offered it one request ago. The admin needs to be + # told which column and why, or they will simply try again. + blind = sorted(_leaf_col_ids(nodes) & row_wall_blind) + if blind: + raise err(400, "bad_filter", + f"{key}: a permission filter cannot use {blind}. Those columns are " + f"computed in the browser, or stored privately for one user, so " + f"the server cannot evaluate them for this account and the rule " + f"would hide EVERY row with nothing on screen saying why. Hide the " + f"column instead, or filter on one of the database's own columns " + f"or on a column shared with the whole workspace.") + cleaned = aios_grid.clean_filter_tree(nodes, filter_keys, cohort_ids=None) + if _leaf_count(cleaned) != _leaf_count(nodes): + raise err(400, "bad_filter", + f"{key}: the filter contains conditions this module cannot evaluate " + f"(an unknown field, an unknown operator, or a cohort leaf — cohorts " + f"are per-user and cannot be a permanent rule). Refused rather than " + f"saved with the bad conditions silently removed, which would store " + f"a weaker wall than the one on screen.") + clean_tree = {"conj": conj, "nodes": cleaned} + # ⭐⭐ W38-T19 — `metrics` IS THE FOURTH FIELD OF AN ENTRY, AND ITS DEFAULT IS GRANT. + # `raw.get("metrics", True)` rather than a required key: a PUT composed by an older + # client, or a copy of a record written before this ticket, must not read as a + # revocation. `perm_scope.may_metrics` applies the identical rule on the read side, so + # the validator and the wall cannot disagree about what an absent key means. + out[key] = {"access": bool(raw.get("access", True)), + "filter": clean_tree, + "hiddenFields": sorted(submitted), + "metrics": bool(raw.get("metrics", True))} + _refuse_unshapeable_bu(out) + return out + + +def _refuse_unshapeable_bu(perms_out): + """⛔ A BU CONDITION THE PUSHDOWN CANNOT READ IS A VALUE LEAK WEARING A CORRECT ROW LIST. + + Amendment 3 in one more place. `perm_scope.derive_pool_scope` recognises exactly the shapes + that PIN a business unit: a top-level `dba eq` leaf, or a top-level OR-group of them — which + is what `perm_migrate` emits and what the condition builder produces for "is any of". An + admin composing the same intent a slightly different way (a NESTED group, `dba neq Royal`, a + `dba` leaf one level down) produces a filter that still narrows the ROWS correctly through + `permits()` — and leaves the pool built CONSOLIDATED, so every surviving row carries + Fisch+Royal numbers. The row list looks right. The revenue is another BU's. + + That is precisely the defect amendment 3 exists for, re-entering through the editor R9 just + shipped, and it cannot be caught downstream: by then the numbers are simply wrong, with + nothing anomalous about them. + + So it is refused at the WRITE, where a person is present to fix it. The discriminator is + narrow on purpose — the filter must MENTION `dba` and must fail to pin a team. A wall like + `dba is Fisch OR revenue > 1000` genuinely does not confine anyone to one BU, and + consolidated values are the correct answer for it, so it is not caught here (`derive` also + refuses it) and must not be. + """ + import core.perm_scope as perm_scope + + for key, e in perms_out.items(): + tree = e.get("filter") + if not tree or not _mentions_dba(tree.get("nodes")): + continue + probe = {"role": "user", "perms_v": perm_scope.PERMS_VERSION, + "perms": {key: {"access": True, "filter": tree, "hiddenFields": []}}} + team_id, _ = perm_scope.derive_pool_scope(probe, key) + if team_id is None and _confines_to_one_bu(tree): + raise err(400, "bu_condition_shape", + f"{key}: this filter restricts the business unit in a way the data layer " + f"cannot push into the query, so the rows would be correct while the " + f"revenue figures on them stayed consolidated across both units. Express " + f"the business-unit condition as a TOP-LEVEL condition — 'DBA is Fisch', " + f"or an 'any of' over the brands — and keep any other rules alongside it.") + + +def _guard_bu_widening(rec, cleaned, confirmed): + """⛔ THE TWO-CLICK LEAK: a SECOND save that silently drops the business-unit condition. + + `_fold_legacy_scope` runs only while a record is un-migrated, which is correct — after the + first save the filter IS the whole wall and the admin owns it. But the first save also + CHANGES the tree (it folds the BU condition in), so a client holding the tree it submitted + rather than the one the server returned will, on its next save, PUT a payload with no BU + condition — and a whole-record replace deletes it. Measured, not theorised: the same client + payload saved twice takes the pool scope from `(6, 'Ann')` to `(None, 'Ann')`, which is both + business units. + + So a save that REMOVES the brand confinement is refused unless it says it means to. Widening + BU access is a legitimate thing for an administrator to do — it is just never a thing to do + by accident, and the difference between the two is one explicit flag. + + ⚠ Deliberately NOT solved by always folding the legacy scope: that would make a BU + restriction permanent and unremovable, which contradicts R1 (the filter is the wall, and the + admin edits it). The right shape is "you may widen, say so". + """ + import core.perm_scope as perm_scope + + if confirmed: + return + stored = (rec.get("perms") or {}) if isinstance(rec.get("perms"), dict) else {} + for key, new_entry in cleaned.items(): + if not new_entry.get("access", True): + # Revoking the module entirely is the OPPOSITE of widening, and the filter on a + # denied entry governs nothing. Guarding it would make "lock this account out" + # require a confirm_widen flag, which reads as nonsense to whoever hits it. + continue + old = stored.get(key) + if not isinstance(old, dict): + continue # nothing stored yet: the fold already handled it + old_tree, new_tree = old.get("filter"), new_entry.get("filter") + if not old_tree or not _confines_to_one_bu(old_tree): + continue # was not confined -> this save cannot widen it + if new_tree and _confines_to_one_bu(new_tree): + continue # still confined -> not a widening + raise err(400, "bu_widening", + f"{key}: this would REMOVE the business-unit restriction and give the account " + f"both units. If that is intended, resend with confirm_widen: true. If it is " + f"not, reload the account's permissions first — saving a filter loaded before " + f"the last change drops the conditions it did not know about.") + # A guard is only as good as its trigger: `perm_scope` is imported so a future reader sees + # the connection to derive_pool_scope, which is what the removed condition was feeding. + _ = perm_scope + + +def _mentions_dba(nodes): + for n in nodes or (): + if not isinstance(n, dict): + continue + if isinstance(n.get("children"), list): + if _mentions_dba(n["children"]): + return True + elif n.get("colId") == "dba": + return True + return False + + +def _confines_to_one_bu(tree): + """Does the BRAND STRUCTURE ALONE confine the reader to one business unit? + + ⚠ "Alone" is the whole precision of this function, and getting it wrong makes the guard + refuse legitimate walls. `dba is Fisch OR ar_open > 1000` does NOT confine anybody — a + high-balance Royal customer satisfies it — so consolidated values are the correct answer and + refusing it would be a false positive. My first attempt evaluated synthetic rows carrying + only `dba`, which made `ar_open > 1000` read false on every probe row and turned that exact + wall into a "confinement" it is not. + + So every NON-`dba` leaf is treated as TRUE — the most generous reading, i.e. "suppose the + reader satisfies everything else; can they still see both brands?" If yes, the wall does not + confine and the pool is correctly consolidated. If no, the brand structure is doing the + confining and `derive_pool_scope` must be able to see it, or the numbers are wrong. + """ + def admits(node, brand): + if isinstance(node.get("children"), list): + kids = [admits(k, brand) for k in node["children"] if isinstance(k, dict)] + if not kids: + return True + return any(kids) if node.get("conj") == "or" else all(kids) + if node.get("colId") != "dba": + return True # every other condition: assume satisfied + want = str(node.get("value") or "").strip().lower() + op = node.get("op") + if op == "eq": + return brand.lower() == want + if op == "neq": + return brand.lower() != want + if op == "contains": + return want in brand.lower() + if op == "doesNotContain": + return want not in brand.lower() + if op == "isEmpty": + return False # a probe brand is never blank + if op == "isNotEmpty": + return True + return True # an op that cannot judge a brand does not confine + + nodes, conj = (tree.get("nodes") or []), tree.get("conj", "and") + root = {"conj": conj, "children": nodes} + admitted = {b for b in ("Fisch", "Royal", "Both") if admits(root, b)} + if not admitted: + return False # admits nothing at all — a different problem + # 'Both' belongs to either unit, so it cannot by itself widen the answer. + return not ({"Fisch", "Royal"} <= admitted) + + +def _leaf_count(nodes): + n = 0 + for node in nodes or (): + if isinstance(node, dict) and isinstance(node.get("children"), list): + n += _leaf_count(node["children"]) + else: + n += 1 + return n + + +def _fold_legacy_scope(rec, cleaned): + """⛔ MIGRATING A RECORD MUST NEVER WIDEN IT. The invariant this function exists to hold. + + Writing perms stamps `perms_v: 1`, and that marker SWITCHES OFF the legacy `bus`/`agent` + fallback in `perm_scope` (amendment 4 — absence must start meaning DENY). So a first write + whose filter carries no BU condition silently promotes a Royal-only account to BOTH business + units: the wall that used to come from `bus` is gone and nothing replaced it. + + Caught by the end-to-end leg in `verify_api` and by nothing else — every model-level check + passed, because the model was doing exactly what it was told. The account simply received + the other BU's customers on the wire. + + So on the FIRST perms write we fold the legacy scope in, as ordinary conditions, exactly as + `perm_migrate` would: this IS R1's migration, performed at the moment the record acquires a + perms block. From then on the admin sees those leaves in the editor (GET returns the folded + filter) and can change them like any other condition — which is R1's whole point, and why + this is a one-time fold rather than a permanent floor AND-ed on every write. + + Idempotent by construction: it runs only while the record is un-migrated, and the write that + calls it is the write that migrates it. + + ⛔ THE FOLD IS PER MODULE, BECAUSE THE LEGACY FILTER IS CUSTOMER-SHAPED AND THE MODULES ARE + NOT. `perm_migrate.filter_for` speaks `dba` and `agent` — both CUSTOMER columns. `product_data` + has neither (19 fields, no brand column at all), and `apply_row_scope` evaluates the permanent + filter with `permits()`, which DENIES anything unanswerable. So folding the customer wall into + the product entry does not narrow the product grid, it EMPTIES it: measured at 0 rows of 2 for + a `bus:[5]` account the moment an admin pressed Save. + + Nothing was wrong upstream — `_clean_perms` already refuses a `dba` condition typed against + `product_data` (its leaf keys are validated per topic). This fold was the ONLY door a + cross-topic leaf could come through, which is why it is the only place that needs the rule. + + ⚠ WHAT IS LOST HERE IS RECOVERED IN `perm_scope.derive_pool_scope`, NOT DISCARDED. Dropping the + BU condition from an AND root WIDENS that module's row scope, and for the product grid the BU + is a VALUES question anyway (`team_id` shapes `rev_ytd`/`qty_ytd`/`inv_value` — amendment 3). + The pushdown falls back to the record's own `bus` for exactly the modules whose field + vocabulary cannot express a BU, so the product pool is still BUILT as Fisch. The two halves + ship together or the second one is a leak. + """ + import core.perm_migrate as perm_migrate + import core.perm_scope as perm_scope + + if perm_scope.is_migrated(rec): + return cleaned + legacy = perm_migrate.filter_for(rec) + if not legacy: + return cleaned # nothing to preserve: the account was unrestricted + out = {} + for key, e in cleaned.items(): + legacy_here = _prune_to_module(legacy, {f["key"] for f in _module_fields(key)}) + tree = e.get("filter") + if not legacy_here: + # This module cannot evaluate ANY of the legacy conditions. Fold nothing rather than + # storing a wall it will read as "deny every row". + out[key] = e + continue + if not tree or not tree.get("nodes"): + folded = legacy_here + else: + # AND the two roots together. The submitted tree keeps its own conjunction by being + # nested as a GROUP — flattening an `or` tree into an `and` root would turn the + # admin's "A or B" into "A and B", which is a different and much narrower wall. + folded = {"conj": "and", + "nodes": list(legacy_here["nodes"]) + [{"conj": tree.get("conj", "and"), + "children": list(tree["nodes"])}]} + out[key] = dict(e, filter=folded) + return out + + +def _prune_to_module(tree, valid_keys): + """`tree` with every leaf this module cannot evaluate removed, or None if nothing survives. + + Used ONLY on the legacy fold above, where the alternative to pruning is a filter that denies + every row. It is not a general sanitiser: `_clean_perms` REFUSES rather than drops, for the + reason its own docstring gives (a silently-weakened wall reads as "Saved"). + + ⚠ A GROUP THAT LOSES ANY CHILD IS DROPPED WHOLE. Half of an `or` group is a narrower rule than + the admin's intent and half of an `and` group is a wider one; neither is the thing that was + written, and a fold has no standing to invent a third meaning. All-or-nothing per group keeps + the surviving conditions ones somebody actually declared. + """ + if not isinstance(tree, dict): + return None + + def keep(node): + if not isinstance(node, dict): + return None + kids = node.get("children") + if isinstance(kids, list): + surviving = [keep(k) for k in kids] + if not kids or any(s is None for s in surviving): + return None + return dict(node, children=surviving) + return node if node.get("colId") in valid_keys else None + + nodes = [n for n in (keep(n) for n in tree.get("nodes") or ()) if n is not None] + return {"conj": tree.get("conj", "and"), "nodes": nodes} if nodes else None + + +@router.get("/admin/users/{username}/perms") +def get_perms(username: str, session: Session = Depends(admin_gate)): + """This account's permission block plus the field schema the editor needs to render it.""" + reg = _registry() + uname = str(username or "").strip().lower() + if uname not in reg or _tenant_of(reg[uname]) != _tenant_of(session.user): + # Wave 18 (C1-TENANT): a cross-tenant username answers exactly like a missing one — + # 404, never 403, because "that account exists in another company" is itself a leak. + raise err(404, "no_such_user", f"no account named {uname!r}") + import core.perm_scope as perm_scope + + rec = reg[uname] + stored = rec.get("perms") or {} + # S3 ask 3 — AN ENTRY FOR EVERY DECLARED MODULE, never a gap the client has to default. + # The PUT is a whole-record replace over this same module list, so a key declared here and + # omitted from `perms` would become an explicit DENY the moment anyone pressed Save. The + # default sent is the EFFECTIVE one (`may_access`), so an un-migrated record shows the + # access its legacy grant currently gives rather than a guess in either direction. + # + # ⭐⭐ WAVE 33 (W33-T33, owner item 11) — THE LIST IS THIS TENANT'S, NOT A LITERAL. Everything + # below was built from `_PERM_MODULES` and never touched `session.runtime`, which is why a + # nurilab or gtmlab admin opened Manage user and saw Royal Imports' Customer and Product + # databases. `_perm_modules` applies the tenant's own provisioning (the rule `routes_nav` + # has applied since wave 18) and merges the tenant's own `ut_*` databases. + modules = _perm_modules(session, surfaces=True) + # ⭐⭐ W36-T22 / C2 — EVERY listed database, not an `enforced` subset of them. See + # `perms.tenant_governable_modules` for why the flag is deleted rather than defaulted. + governed = [m["key"] for m in modules] + # ⭐ ONE `_module_fields` PASS, SHARED BY THE PICKER AND THE CASCADE. It used to be built + # inline at the return; hoisted so the prune below reads the SAME vocabulary the payload + # offers rather than paying a second store round-trip per database to ask again. + fields_by_module = {k: _module_fields(k, session=session) for k in governed} + # ⭐⭐ W40-T05 / OWNER I16 — THE CASCADE, AT THE ADMIN DOOR. *"If the field is deleted, its + # permission filter goes with it."* A stored leaf naming a column the database no longer has + # is not ignored by the wall — `apply_row_scope` uses `permits`, which DENIES what it cannot + # answer — so a deleted column silently converts that account's grid to zero rows. Dropping + # the leaf here means the editor renders, and the record stores, only rules that still exist. + # + # ⛔⛔ GUARDED ON `is_migrated`, AND THAT GUARD IS A BU LEAK AWAY FROM OPTIONAL. + # `users.set_access(perms=…)` STAMPS `perms_v` in the same read-modify-write, so writing here + # would MIGRATE an un-migrated record — and `_fold_legacy_scope` runs only WHILE a record is + # un-migrated. A GET would then consume the one chance to fold the legacy `bus`/`agent` scope + # in, and the account would silently acquire the other business unit's customers. The + # `stored` test alone would very nearly cover it (an un-migrated record carries no perms + # block), so the marker is asked explicitly rather than inferred from an empty dict. + if stored and perm_scope.is_migrated(rec): + _pruned, _gone = _prune_stale_filters(stored, session=session) + if _gone: + users.set_access(uname, perms=_pruned) + # Re-read rather than trust the write: this route's whole job is to report what is + # STORED, and a store that took nothing must not be reported as if it had. + rec = _registry().get(uname) or rec + stored = rec.get("perms") or _pruned + perms_out = {} + for k in governed: + e = stored.get(k) + # ⛔⛔ W36-T22 — THE DEFAULT IS `may_read`, NOT `may_access`, AND THE DIFFERENCE IS AN + # OUTAGE. `may_access` reads migrated-and-undeclared as DENY. That is right for a registry + # topic and catastrophic for a `ut_*` key, because no `ut_*` entry was STORABLE before this + # wave — so every migrated account carries none, and this route would default every one of + # them to `access: false`. + # + # ⛔ AND IT WOULD NOT HAVE STAYED A DISPLAY BUG FOR LONG. C2 also deleted the filters that + # kept `ut_*` keys OUT of the PUT body, so the editor now sends every declared module: an + # administrator opening this page, changing one unrelated filter and pressing Save would + # write an EXPLICIT `{access: false}` for all ten keychain databases — at which point + # `may_read`'s deny-only overlay fires and revokes them for real. One click, silent, + # permanent. Defaulting from the SAME evaluator the read door uses is what makes the page + # show what the user can actually open, so a save of an untouched payload is a no-op. + # ⚠ `_public(uname, rec)`, NOT the raw record. A stored record is keyed BY username in + # `users.json` and does not carry one INSIDE it, so `may_read` would hand `may_open` a + # `None` viewer and get a fail-closed False — the very outage this line exists to prevent, + # arriving through the fix for it. The same trap cost a gate double an hour earlier today. + # ⛔⛔ A SURFACE KEY DEFAULTS FROM `nav_may_open`, NOT `may_read`, AND THE + # DIFFERENCE IS A MASS REVOCATION. `may_read` on a non-`ut_` key falls through to + # `may_access`, which reads migrated-and-undeclared as DENY — correct for a topic + # (every save writes topic entries) and false for a surface, because NO surface entry + # was storable before 2026-08-18, so every migrated account carries none. The editor + # would paint Assistant unchecked for an account that opens it every day, and the next + # save of ANY unrelated change would write the explicit deny for real. `nav_may_open` + # is the evaluator the nav and the route gate actually ask, legacy fallback included, + # so the box shows what the account can reach and an untouched save stays a no-op. + _default = (perm_scope.nav_may_open(users._public(uname, rec), k) + if any(m.get("surface") and m["key"] == k for m in modules) else + perm_scope.may_read(users._public(uname, rec), k, st=session.runtime)) + # ⭐⭐ W38-T19 — `metrics` RIDES EVERY ENTRY, BACKFILLED TO GRANTED ON A RECORD WRITTEN + # BEFORE THE KEY EXISTED. The wire is TOTAL on purpose: the client's own parse already + # reads an absent key as granted, so this line changes no rendering — what it changes is + # what a HUMAN reads off the payload while debugging, and what the PUT round-trips. The + # stored dict is copied rather than mutated: `rec` is the live registry record and + # stamping a key onto it here would write a permission nobody saved. + perms_out[k] = (dict(e, metrics=bool(e.get("metrics", True))) + if isinstance(e, dict) else + {"access": bool(_default), "filter": None, "hiddenFields": [], + "metrics": True}) + return {"username": uname, + "perms": perms_out, + # S3 ask 2 — the marker rides the GET. An absent module entry means DENY on a + # migrated record and LEGACY on an un-migrated one: opposite meanings for identical + # JSON, so the client cannot render honestly without knowing which world it is in. + "perms_v": int(rec.get("perms_v") or 0), + # `role == 'admin'` bypasses perms entirely (amendment 4) — sent so the editor can + # say "Everything (admin)" instead of rendering stored rules that do not apply. + "is_admin": perms.is_admin(rec), + # ⭐⭐ W36-T22 / C2 — `{key, label}` per row and NO `enforced`. It used to ride here + # so the editor could paint an apology ("Not set here") for a database whose wall was + # never applied; W36-T21 armed every one of them, so the flag would now be constantly + # true and the apology would be a lie with a green gate behind it. The LABEL comes + # from the registry / the table's own definition. + "modules": modules, + # S3 ask 1 — CANONICAL SHAPE: a BARE ARRAY of fields per module key. Not the nav + # schema envelope; the editor needs the field list and nothing else, and one shape + # beats two readings of a sentence. + # ⭐ EVERY governed database now carries its fields, `ut_*` included — that is owner + # item 11's *"EVERY database should be able to be toggleable by admin"*, and it is + # the SAME resolver `_clean_perms` validates against, so the picker cannot offer a + # column the validator will reject. + "fields_by_module": fields_by_module} + + +@router.put("/admin/users/{username}/perms") +def put_perms(username: str, body: dict = Body(default=None), + session: Session = Depends(admin_gate)): + """Replace this account's permission block wholesale. + + WHOLESALE, not a merge: "remove this restriction" has to be expressible, and a merge cannot + express a deletion without a second vocabulary for it. + + ⚠ NO EPOCH BUMP, for the same reason `PATCH /admin/users/{u}` does not bump one: + `deps._user_for` re-reads the record on every request, so a narrowed wall applies to the + target's LIVE session on its very next call. Bumping would only add a forced re-login on top + of a change that has already taken effect. + """ + body = body if isinstance(body, dict) else {} + reg = _registry() + uname = str(username or "").strip().lower() + if uname not in reg or _tenant_of(reg[uname]) != _tenant_of(session.user): + # Wave 18 (C1-TENANT): a cross-tenant username answers exactly like a missing one — + # 404, never 403, because "that account exists in another company" is itself a leak. + raise err(404, "no_such_user", f"no account named {uname!r}") + if "perms" not in body: + raise err(400, "empty_patch", "no perms to save") + + # THE SELF-LOCKOUT GUARD, the twin of `self_demote` on the PATCH route. An admin bypasses + # perm_scope entirely, so this cannot brick them today — but it stops an administrator + # writing a wall for themselves that would bite the moment their role changed. + if uname == session.uname: + raise err(400, "self_perms", + "you cannot set permissions on your own account — ask another admin") + + _mods = _perm_modules(session, surfaces=True) + # ⭐⭐ W36-T22 / C2 — ONE key set. It was two ("listed" and "enforced") with a refusal in the + # gap; W36-T21 closed the gap, so every database the editor shows is one whose wall the table + # routes apply. `session` rides so a `ut_*` schema can be read for THIS tenant. + cleaned = _clean_perms(body.get("perms"), + governed_keys={m["key"] for m in _mods}, session=session) or {} + cleaned = _fold_legacy_scope(reg[uname], cleaned) + _guard_bu_widening(reg[uname], cleaned, bool(body.get("confirm_widen"))) + users.set_access(uname, perms=cleaned) + fresh = _registry() + rec = fresh.get(uname) or {} + if int(rec.get("perms_v") or 0) < users.PERMS_VERSION: + # The store took the write and did not record it. A 200 here would tell an administrator + # the wall is up when it is not — the one direction this must never fail in. + raise err(503, "store_unavailable", + "the permissions were not saved — the account is UNCHANGED") + return {"username": uname, "perms": rec.get("perms") or {}, + "perms_v": int(rec.get("perms_v") or 0)} + + +def _store_binding(session): + """`data_binding.describe()` for the store THIS SESSION's tenant actually writes. + + ⭐ THE TENANT'S STORE, NOT THE PROCESS DEFAULT, AND THAT DISTINCTION IS THE POINT. Tenant #0 + rides `core.store`'s module default, but nurilab/gtmlab/loopable are bound to their OWN dataset + repos by `harness/runtime.py:470`, straight out of the control-plane record — which is the path + that carried three tenants' PRODUCTION stores onto staging while `--data-repo` isolated only + tenant #0. Reporting the process default here would show "staging store, all fine" to exactly + the tenants that were not fine. + + ⚠ Degrades to a stated `unknown` rather than raising: a settings page that 500s because it + could not describe the store is worse than one that says it does not know. + """ + try: + import core.data_binding as data_binding # noqa: PLC0415 + import core.store as store # noqa: PLC0415 + bound = getattr(session.runtime, "store_handle", None) + repo = getattr(bound, "repo", None) or store.REPO + return data_binding.describe(repo) + except Exception as e: # noqa: BLE001 + return {"deployment": "unknown", "production": False, "repo": "", + "writable": False, "refusal": f"could not resolve the store binding: {e}", + "localOverride": False, "allowlist": [], "observed": {}} + + +@router.get("/settings") +def settings(session: Session = Depends(require_session)): + """The session's OWN settings — any authenticated user, not admin-only. + + `user` comes from `routes_auth._public_user`, deliberately reusing the ONE definition of what a + client may know about itself (never a hash, never the epoch). `scope` restates it as the two + things the Settings UI shows, and `tenant` names only THIS tenant — enumerating the others + would answer a question about our customer list. + """ + modules = perms.allowed_modules(session.user) + return { + # The build this Space is running. Behind the session on purpose — see `main.py::health`, + # which refuses it for being the first URL a scanner finds. `deploy_web.py` stamps it as a + # Space secret at every deploy; absent means "somebody started this container by hand". + "version": os.environ.get("AIOS_VERSION") or "unknown", + "user": _public_user(session.user), + "scope": { + "bus": perms.allowed_bu_labels(session.user), + "team_id": perms.scope_team_id(session.user), + "agent": perms.scope_agent(session.user), + "modules": sorted(modules) if modules is not None else "all", + # C-PERM: the caller's OWN effective wall, read-only. The grid uses it to grey + # pickers rather than to enforce — hidden fields are stripped from every data wire + # regardless, so a client that ignores this is narrowed anyway, never widened. + "perms": session.user.get("perms") or {}, + }, + "tenant": {"key": session.tenant, "name": getattr(session.runtime, "name", + session.tenant)}, + # ⭐⭐ D-315 — WHICH STORE THIS CONTAINER IS BOUND TO, AND WHETHER IT MAY WRITE IT. + # + # ⛔ THIS IS NOT DIAGNOSTIC GARNISH; IT IS THE ONLY WAY THE QUESTION CAN BE ANSWERED. + # D-160 established that a Space's environment cannot be read from outside, so "is staging + # pointed at production data?" is a question only the container can answer — and it went + # unanswered for the whole window in which two builds wrote tenant #0's real store. + # `verify_live` asserts this field after logging in, which is a check that survives a role + # swap in a way that reading a deploy log never did. + # + # ⚠ Session-gated, beside `version`, for `main.py::health`'s reason: a Space id and a + # dataset repo id are public names, but a health endpoint is the first URL a scanner finds + # and it may not describe the deployment. No customer data is here — only which store, and + # yes or no. + "store": _store_binding(session), + # CHROME ONLY. Every /admin route re-checks the role server-side; this exists so the client + # does not paint a "Manage users" button that 403s. + "admin": perms.is_admin(session.user), + # Wave 19 (R3, contract C2): the same courtesy for the LOOPABLE plane — the Settings rail + # paints its entry iff this is true, and `routes_platform_admin` refuses every request + # that is not, so a client that ignored this would gain exactly nothing. Derived from the + # ONE predicate rather than restated: a second copy of a two-condition wall is a second + # place for one of the conditions to go missing. + "platformAdmin": platform_admin.is_platform_admin(session.user), + }