| """Per-user accounts for the platform, persisted in the HF Dataset store (users.json). |
| |
| Passwords are salted + PBKDF2-HMAC-SHA256 (200k iterations) — never stored or logged in plaintext. |
| A bootstrap 'admin' account is seeded from APP_PASSWORD so the owner can always log in and create |
| users; APP_PASSWORD also works as an emergency master for 'admin' if the registry is unreachable. |
| |
| Each account carries BU access ('all' or a list of team-ids [5=Fisch, 6=Royal]) which drives |
| allowed_bus() — the basis for per-Business-Unit permissioning (a Royal-only user never sees Fisch). |
| """ |
| import os |
| import hmac |
| import hashlib |
| import secrets |
|
|
| import core.store as store |
|
|
| BU_LABELS = {5: 'Fisch', 6: 'Royal'} |
| _ITER = 200_000 |
|
|
| |
| |
| PERMS_VERSION = 1 |
|
|
|
|
| def _hash(pw, salt): |
| return hashlib.pbkdf2_hmac('sha256', str(pw).encode('utf-8'), bytes.fromhex(salt), _ITER).hex() |
|
|
|
|
| def _record(pw, name, role, bus, active=True, modules='all', agent=None, email=None, |
| perms=None, tenant='royal-imports', platform_admin=False): |
| salt = secrets.token_hex(16) |
| rec = {'salt': salt, 'hash': _hash(pw, salt), 'name': name, 'role': role, |
| 'bus': bus, 'active': active, 'modules': modules, |
| 'agent': agent or None, 'email': email or None, |
| |
| |
| |
| 'tenant': str(tenant or 'royal-imports').strip().lower()} |
| if platform_admin is True: |
| |
| |
| |
| |
| rec['platform_admin'] = True |
| if perms is not None: |
| |
| |
| |
| |
| rec['perms'] = perms |
| rec['perms_v'] = PERMS_VERSION |
| return rec |
|
|
|
|
| def registry(): |
| return store.get('users') |
|
|
|
|
| def ensure_bootstrap(): |
| """Seed an 'admin' account from APP_PASSWORD ONLY on a truly fresh store (no users file yet). |
| Idempotent; no-op if the store is unavailable (the app then falls back to the master-password |
| path in verify()). |
| |
| Critically, this NEVER overwrites an existing registry: it seeds only when store.exists('users') |
| is definitively False. A transient read failure at startup used to return {} and make this |
| re-seed just {admin} over the real accounts — that is the bug that wiped users on restart.""" |
| if not store.available(): |
| return |
| if store.exists('users'): |
| return |
| try: |
| reg = store.get('users', fresh=True) |
| except Exception: |
| return |
| if reg: |
| return |
| master = os.environ.get('APP_PASSWORD', '') |
| if not master: |
| return |
| try: |
| store.put('users', {'admin': _record(master, 'Administrator', 'admin', 'all')}) |
| except Exception: |
| pass |
|
|
|
|
| def _public(username, u): |
| return {'username': username, 'name': u.get('name', username), |
| 'role': u.get('role', 'user'), 'bus': u.get('bus', 'all'), |
| 'modules': u.get('modules', 'all'), |
| 'agent': u.get('agent'), 'email': u.get('email'), |
| |
| |
| 'tenant': str(u.get('tenant') or 'royal-imports').strip().lower(), |
| |
| |
| |
| 'avatar': u.get('avatar') or None, |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| **({'perms': u['perms']} if isinstance(u.get('perms'), dict) else {}), |
| **({'perms_v': int(u['perms_v'] or 0)} if u.get('perms_v') else {}), |
| |
| |
| |
| |
| |
| |
| |
| |
| **({'platform_admin': True} if u.get('platform_admin') is True else {}), |
| 'epoch': int(u.get('epoch') or 0)} |
|
|
|
|
| |
| |
| |
| |
| |
| |
| def epoch(username): |
| """The current session epoch for `username`. None when there is no such account. |
| |
| None is NOT 0. 0 is "this account exists and has never been revoked"; None is "no record" — |
| which the session verifier must treat as a reason to refuse, not as a default to compare |
| against. (The APP_PASSWORD emergency-master admin has no record at all; the verifier handles |
| that case explicitly rather than inventing an epoch for it here.) |
| """ |
| username = (username or '').strip().lower() |
| try: |
| u = (store.get('users') or {}).get(username) |
| except Exception: |
| return None |
| return int((u or {}).get('epoch') or 0) if u else None |
|
|
|
|
| def bump_epoch(username): |
| """Revoke every outstanding API session for this account.""" |
| username = (username or '').strip().lower() |
|
|
| def _set(reg): |
| u = reg.get(username) |
| if u: |
| u['epoch'] = int(u.get('epoch') or 0) + 1 |
| return reg |
| store.update('users', _set) |
|
|
|
|
| def verify(username, pw): |
| """Return a public user dict on success, else None. APP_PASSWORD is an emergency master for the |
| 'admin' login even if the store is unreachable, so the owner is never locked out.""" |
| username = (username or '').strip().lower() |
| if not username or not pw: |
| return None |
| master = os.environ.get('APP_PASSWORD', '') |
| try: |
| |
| reg = store.get('users', fresh=True) |
| except Exception: |
| reg = {} |
| u = reg.get(username) |
| if u is None and '@' in username: |
| |
| |
| |
| for k, r in reg.items(): |
| if isinstance(r, dict) and str(r.get('email') or '').strip().lower() == username: |
| username, u = k, r |
| break |
| if u and u.get('active', True) and hmac.compare_digest(_hash(pw, u['salt']), u['hash']): |
| return _public(username, u) |
| |
| if username == 'admin' and master and hmac.compare_digest(str(pw), master): |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| return {'username': 'admin', 'name': 'Administrator', 'role': 'admin', 'bus': 'all', |
| 'modules': 'all', 'epoch': int((reg.get('admin') or {}).get('epoch') or 0)} |
| return None |
|
|
|
|
| def create_user(username, pw, name, role='user', bus='all', modules='all', |
| agent=None, email=None, tenant=None, platform_admin=None): |
| """Create — or, from app.py's dialog, OVERWRITE — an account. |
| |
| ⛔ X3: OVERWRITING AN ACCOUNT MUST NOT RESURRECT ITS OLD SESSIONS. `_record()` builds a fresh |
| record with no `epoch` key, i.e. absent == 0. So re-saving an existing username used to reset |
| the epoch to 0, and every cookie minted before that account's last password rotation started |
| verifying again — a silent un-revocation. `app.py`'s "Add / update a user" calls this function |
| for BOTH add and update, so the hole was reachable from the shipped UI. |
| |
| Epoch revocation is only ever as strong as the NARROWEST write path that touches the record, so |
| the carry-and-bump lives here rather than in each caller: an overwrite is at least as |
| session-invalidating as a password change, and it usually IS one. |
| """ |
| username = (username or '').strip().lower() |
| if not username or not pw: |
| raise ValueError('username and password are required') |
|
|
| def _add(reg): |
| prior = reg.get(username) or {} |
| rec = _record(pw, name or username, role, bus, modules=modules, |
| agent=agent, email=email, |
| |
| |
| tenant=(tenant or prior.get('tenant') or 'royal-imports'), |
| |
| |
| |
| |
| |
| platform_admin=(prior.get('platform_admin') is True |
| if platform_admin is None else platform_admin is True)) |
| if prior: |
| rec['epoch'] = int(prior.get('epoch') or 0) + 1 |
| reg[username] = rec |
| return reg |
| store.update('users', _add) |
|
|
|
|
| def set_password(username, pw): |
| username = (username or '').strip().lower() |
|
|
| def _set(reg): |
| u = reg.get(username) |
| if u: |
| u['salt'] = secrets.token_hex(16) |
| u['hash'] = _hash(pw, u['salt']) |
| |
| |
| |
| |
| u['epoch'] = int(u.get('epoch') or 0) + 1 |
| return reg |
| store.update('users', _set) |
|
|
|
|
| def set_active(username, active): |
| username = (username or '').strip().lower() |
|
|
| def _set(reg): |
| if username in reg: |
| reg[username]['active'] = bool(active) |
| |
| |
| |
| reg[username]['epoch'] = int(reg[username].get('epoch') or 0) + 1 |
| return reg |
| store.update('users', _set) |
|
|
|
|
| def set_platform_admin(username, on): |
| """Promote/demote a PLATFORM administrator (wave 19, R3) without touching the password. |
| |
| The narrow write, deliberately: `create_user` is the destructive path (fresh salt, fresh |
| hash, bumped epoch) and promoting somebody should not sign them out or rotate a credential. |
| Cleared by REMOVING the key, so a demoted record goes back to its pre-wave shape rather than |
| carrying a `False` that reads as "somebody considered this". |
| |
| ⚠ This is the flag only. It grants nothing on its own — `core.platform_admin` also demands |
| the `loopable` tenant, and there is no code path anywhere that moves an account between |
| tenants, which is what makes the second lock hold. |
| """ |
| username = (username or '').strip().lower() |
|
|
| def _set(reg): |
| u = reg.get(username) |
| if u: |
| if on is True: |
| u['platform_admin'] = True |
| else: |
| u.pop('platform_admin', None) |
| return reg |
| store.update('users', _set) |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| def _now_iso(): |
| import datetime as _dt |
| return _dt.datetime.now(_dt.timezone.utc).isoformat(timespec='seconds') |
|
|
|
|
| |
| |
| _STAMPS = [] |
| _STAMPS_LOCK = __import__('threading').Lock() |
|
|
|
|
| def _stamp(username, fields): |
| """Merge `fields` into ONE account record, on a background thread, with a fresh read.""" |
| username = (username or '').strip().lower() |
| if not username or not fields: |
| return None |
|
|
| def _set(reg): |
| u = reg.get(username) |
| if isinstance(u, dict): |
| u.update(fields) |
| return reg |
|
|
| def _work(): |
| try: |
| store.update('users', _set) |
| except Exception: |
| pass |
| import threading |
| t = threading.Thread(target=_work, daemon=True, name=f'user-stamp:{username}') |
| with _STAMPS_LOCK: |
| _STAMPS[:] = [x for x in _STAMPS if x.is_alive()] |
| _STAMPS.append(t) |
| t.start() |
| return t |
|
|
|
|
| def flush_stamps(timeout=10.0): |
| """Block until outstanding stamp writes have been applied. For gates and shutdown hooks — |
| the app never needs it, exactly like `core.store.flush`.""" |
| with _STAMPS_LOCK: |
| pending = list(_STAMPS) |
| for t in pending: |
| t.join(timeout=timeout) |
| return all(not t.is_alive() for t in pending) |
|
|
|
|
| def touch_login(username, when=None): |
| """Stamp `last_login` (ISO-8601, UTC, OFFSET-BEARING) on a SUCCESSFUL login. |
| |
| `username` must be the RESOLVED account key, not what the person typed: `verify()` accepts an |
| email address and resolves it to the registry key, so stamping the typed identifier would |
| write a stamp onto a key that does not exist and create a phantom account in the registry. |
| |
| `last_active` rides along — signing in IS activity, and setting both here means the plane's |
| two columns agree the moment somebody logs in rather than an hour later. |
| """ |
| stamp = when or _now_iso() |
| return _stamp(username, {'last_login': stamp, 'last_active': stamp}) |
|
|
|
|
| def touch_active(username, when=None): |
| """Stamp `last_active` — "this session did something". Throttled BY THE CALLER (`deps.py` |
| holds a process-local last-seen map), so this is not a store round-trip per request.""" |
| return _stamp(username, {'last_active': when or _now_iso()}) |
|
|
|
|
| def set_access(username, role=None, bus=None, modules=None, agent=None, email=None, |
| name=None, perms=None): |
| """Update access fields. agent/email: pass '' to clear, None to leave unchanged — |
| the user↔agent link scopes the Customer List page / digests to that agent's book. |
| |
| `name` follows the same None-means-unchanged idiom. It is here because it had no setter at all: |
| a display name could previously only be changed by re-creating the record through |
| `create_user`, i.e. by also resetting the password (and, before the fix above, the session |
| epoch). Y4's `PATCH {name?}` needs the narrow write, not the destructive one.""" |
| username = (username or '').strip().lower() |
|
|
| def _set(reg): |
| u = reg.get(username) |
| if u: |
| if name is not None: |
| u['name'] = name |
| if role is not None: |
| u['role'] = role |
| if bus is not None: |
| u['bus'] = bus |
| if modules is not None: |
| u['modules'] = modules |
| if agent is not None: |
| u['agent'] = agent or None |
| if email is not None: |
| u['email'] = email or None |
| if perms is not None: |
| |
| |
| |
| |
| u['perms'] = perms |
| u['perms_v'] = PERMS_VERSION |
| return reg |
| store.update('users', _set) |
|
|
|
|
| def allowed_bus_labels(user): |
| """BU labels this user may select. 'all' -> All+Fisch+Royal; a single BU -> just that BU (no |
| 'All', so the other BU is never reachable); multiple -> All + each.""" |
| bus = (user or {}).get('bus', 'all') |
| if bus == 'all': |
| return ['All', 'Fisch', 'Royal'] |
| labels = [BU_LABELS[b] for b in bus if b in BU_LABELS] |
| if not labels: |
| return ['All', 'Fisch', 'Royal'] |
| return (['All'] + labels) if len(labels) > 1 else labels |
|
|
|
|
| def assignable_people(tenant=None): |
| """Display names for `user`-typed overlay columns — the tenant's ACTIVE accounts. |
| |
| Moved from app.py (2026-07-31) so both hosts serve the same choices. Resolved on every |
| call rather than persisted with the column: a snapshot would keep offering people who |
| have left and never offer people who joined. Deactivated accounts are excluded; a value |
| already stored on a row is untouched — history should still say who owned something. |
| |
| Wave 18 (C1-TENANT): pass `tenant` to scope the choices to ONE company — the user registry |
| is a global control-plane bucket, and a Nurilab picker offering Royal's staff is a |
| cross-tenant name leak. None = unscoped (the Streamlit host, tenant #0's process). |
| """ |
| try: |
| reg = registry() or {} |
| except Exception: |
| return [] |
| want = str(tenant or '').strip().lower() |
| out = [] |
| for username, u in reg.items(): |
| if not isinstance(u, dict) or u.get('active') is False: |
| continue |
| if want and str(u.get('tenant') or 'royal-imports').strip().lower() != want: |
| continue |
| out.append(str(u.get('name') or username)) |
| return sorted(set(out)) |
|
|
|
|
| def set_avatar(username, data_url): |
| """Set (or clear, with None/'') the user's profile photo — a data URL (wave 14 C-AVATAR, |
| [[loopable-wave14-split]] item 11). Stored VERBATIM; the API route owns validation (mime + |
| decoded size) because this value is served back to every grid session. Cleared by removing |
| the key, so records without a photo keep their pre-wave shape.""" |
| username = (username or '').strip().lower() |
|
|
| def _set(reg): |
| u = reg.get(username) |
| if u: |
| if data_url: |
| u['avatar'] = str(data_url) |
| else: |
| u.pop('avatar', None) |
| return reg |
| store.update('users', _set) |
|
|
|
|
| def avatar_map(tenant=None): |
| """{display name -> avatar data URL} for ACTIVE accounts with a photo — the companion of |
| `assignable_people()`, keyed by the SAME vocabulary: a `user` cell stores the display |
| name, so the display name is the only join a renderer has. Two active accounts sharing a |
| display name share one option; the first WITH a photo wins the key rather than a coin |
| flip deciding whether the option has a face. `tenant` scopes it exactly as |
| `assignable_people(tenant)` does, and for the same leak.""" |
| try: |
| reg = registry() or {} |
| except Exception: |
| return {} |
| want = str(tenant or '').strip().lower() |
| out = {} |
| for username, u in sorted(reg.items()): |
| if not isinstance(u, dict) or u.get('active') is False: |
| continue |
| if want and str(u.get('tenant') or 'royal-imports').strip().lower() != want: |
| continue |
| av = u.get('avatar') |
| nm = str(u.get('name') or username) |
| if av and nm not in out: |
| out[nm] = str(av) |
| return out |
|
|