| """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: [<unknown id>]` 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 |
| 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") |
|
|
| |
| |
| |
| _UNAME_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{1,31}$") |
| |
| |
| |
| _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): |
| """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.""" |
| 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)), |
| |
| |
| "epoch": int(rec.get("epoch") or 0), |
| |
| |
| |
| "accessSummary": _access_summary(rec), |
| "perms_v": int(rec.get("perms_v") or 0)} |
|
|
|
|
| def _access_summary(rec): |
| """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.""" |
| import core.perm_scope as perm_scope |
|
|
| if perms.is_admin(rec): |
| |
| |
| 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 {}) |
| 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 (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 |
|
|
|
|
| |
| def _clean_username(v): |
| uname = str(v or "").strip().lower() |
| if not _UNAME_RE.match(uname): |
| raise err(400, "bad_username", |
| "a username is 2-32 characters: lowercase letters, digits, dot, dash or " |
| "underscore, starting with a letter or digit") |
| 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() |
|
|
|
|
| |
| @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) |
| return {"users": [_view(u, reg[u]) 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 |
|
|
| if uname in _registry(): |
| raise err(409, "user_exists", f"{uname} already exists β PATCH it to change it") |
| |
| |
| 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: |
| |
| |
| 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): |
| |
| |
| 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: |
| |
| 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 |
| |
| |
| |
| 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() |
| 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) |
|
|
| 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): |
| |
| |
| 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: |
| |
| |
| raise err(503, "store_unavailable", |
| "the password change did not persist β outstanding sessions were NOT revoked") |
| return Response(status_code=204) |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| _PERM_MODULES = ("customer_data", "product_data") |
|
|
|
|
| def _module_fields(key): |
| """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. |
| """ |
| import aios_grid |
| src = aios_grid.product_fields() if key == "product_data" else aios_grid.FIELDS |
| out = [] |
| for f in src: |
| if not isinstance(f, dict) or not f.get("key"): |
| continue |
| out.append({"key": f["key"], "label": f.get("label") or f["key"], |
| "type": f.get("type") or "text", |
| **({"options": f["options"]} if isinstance(f.get("options"), list) else {}), |
| **({"pinned": True} if f.get("pinned") else {})}) |
| return out |
|
|
|
|
| def _clean_perms(v): |
| """Validate a whole `perms` block. FAIL-CLOSED, and LOUD rather than lenient. |
| |
| β 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") |
| unknown = sorted(set(v) - set(_PERM_MODULES)) |
| if unknown: |
| raise err(400, "bad_perms", |
| f"unknown module keys: {unknown} β the permission editor governs " |
| f"{list(_PERM_MODULES)}") |
| 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") |
| valid_keys = {f["key"] for f in _module_fields(key)} |
| hidden = raw.get("hiddenFields") or [] |
| if not isinstance(hidden, list): |
| raise err(400, "bad_perms", f"{key}: hiddenFields must be a list") |
| bad = sorted({str(h) for h in hidden} - valid_keys) |
| if bad: |
| |
| |
| 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"] |
| cleaned = aios_grid.clean_filter_tree(nodes, valid_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 a " |
| f"weaker wall than the one on screen.") |
| clean_tree = {"conj": conj, "nodes": cleaned} |
| out[key] = {"access": bool(raw.get("access", True)), |
| "filter": clean_tree, |
| "hiddenFields": sorted({str(h) for h in hidden})} |
| _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): |
| |
| |
| |
| continue |
| old = stored.get(key) |
| if not isinstance(old, dict): |
| continue |
| old_tree, new_tree = old.get("filter"), new_entry.get("filter") |
| if not old_tree or not _confines_to_one_bu(old_tree): |
| continue |
| if new_tree and _confines_to_one_bu(new_tree): |
| continue |
| 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.") |
| |
| |
| _ = 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 |
| 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 |
| if op == "isNotEmpty": |
| return True |
| return True |
|
|
| 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 |
| |
| 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 |
| 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: |
| |
| |
| out[key] = e |
| continue |
| if not tree or not tree.get("nodes"): |
| folded = legacy_here |
| else: |
| |
| |
| |
| 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): |
| |
| |
| 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 {} |
| |
| |
| |
| |
| |
| perms_out = {} |
| for k in _PERM_MODULES: |
| e = stored.get(k) |
| perms_out[k] = e if isinstance(e, dict) else { |
| "access": bool(perm_scope.may_access(rec, k)), "filter": None, "hiddenFields": []} |
| return {"username": uname, |
| "perms": perms_out, |
| |
| |
| |
| "perms_v": int(rec.get("perms_v") or 0), |
| |
| |
| "is_admin": perms.is_admin(rec), |
| "modules": [{"key": k, "label": _MODULE_LABELS.get(k, k)} for k in _PERM_MODULES], |
| |
| |
| |
| "fields_by_module": {k: _module_fields(k) for k in _PERM_MODULES}} |
|
|
|
|
| @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): |
| |
| |
| 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") |
|
|
| |
| |
| |
| if uname == session.uname: |
| raise err(400, "self_perms", |
| "you cannot set permissions on your own account β ask another admin") |
|
|
| cleaned = _clean_perms(body.get("perms")) 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: |
| |
| |
| 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)} |
|
|
|
|
| _MODULE_LABELS = {"customer_data": "Customer", "product_data": "Product"} |
|
|
|
|
| @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 { |
| |
| |
| |
| "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", |
| |
| |
| |
| "perms": session.user.get("perms") or {}, |
| }, |
| "tenant": {"key": session.tenant, "name": getattr(session.runtime, "name", |
| session.tenant)}, |
| |
| |
| "admin": perms.is_admin(session.user), |
| |
| |
| |
| |
| |
| "platformAdmin": platform_admin.is_platform_admin(session.user), |
| } |
|
|