Deploy AIOS web (React glide grid + FastAPI slice)
Browse files- RELEASES.json +1 -1
- VERSION +1 -1
- api/aios_session.py +124 -124
- api/deps.py +14 -1
- api/routes_admin.py +0 -0
- api/routes_customers.py +21 -2
- api/routes_products.py +8 -1
- api/routes_slack.py +20 -3
- platform/core/perm_scope.py +0 -0
- platform/core/shared_overlay.py +327 -302
- web/src/settings/permsModel.ts +0 -0
RELEASES.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
{
|
| 2 |
-
"current": "
|
| 3 |
"releases": [
|
| 4 |
{
|
| 5 |
"version": "v52",
|
|
|
|
| 1 |
{
|
| 2 |
+
"current": "240caca",
|
| 3 |
"releases": [
|
| 4 |
{
|
| 5 |
"version": "v52",
|
VERSION
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
|
|
|
|
| 1 |
+
240caca
|
api/aios_session.py
CHANGED
|
@@ -1,124 +1,124 @@
|
|
| 1 |
-
"""aios_session.py β X3: the signed, STATELESS session cookie (EXIT-3a, 2026-07-30).
|
| 2 |
-
|
| 3 |
-
aios_session = base64url(json {t,u,e,i,x}) . base64url(HMAC-SHA256)
|
| 4 |
-
|
| 5 |
-
`t` tenant slug Β· `u` username Β· `e` the user's revocation epoch Β· `i` idle deadline Β·
|
| 6 |
-
`x` absolute expiry. Signed with `AIOS_SESSION_SECRET` using stdlib `hmac` β no new dependency,
|
| 7 |
-
and nothing to store server-side, which is the whole point: EXIT-4 requires a process that holds
|
| 8 |
-
no per-tenant state, and a session TABLE is per-tenant state. D2's Postgres sessions-MIRROR (an
|
| 9 |
-
audit view, not the source of truth) arrives with C-2 and is deliberately deferred.
|
| 10 |
-
|
| 11 |
-
WHY STATELESS STILL REVOKES. A cookie nobody can delete sounds like a cookie nobody can revoke.
|
| 12 |
-
The answer is the `epoch` integer on the user record (`core.users.epoch` / `bump_epoch`): the
|
| 13 |
-
cookie carries the epoch it was minted under, every verification re-reads the account's current
|
| 14 |
-
epoch, and a password change, a deactivation, or (wave 40, R2) any LOGIN bumps it β so every outstanding cookie for that
|
| 15 |
-
user dies at once, without a session table.
|
| 16 |
-
|
| 17 |
-
β NO FALLBACK SECRET, EVER. With `AIOS_SESSION_SECRET` unset this module mints a RANDOM
|
| 18 |
-
per-process key: sessions then die on restart, which is an inconvenience. A hardcoded default
|
| 19 |
-
would instead let anyone who has read this repo forge a cookie for any user of any tenant β an
|
| 20 |
-
inconvenience is the correct trade against a forgery key. `EPHEMERAL_SECRET` records which
|
| 21 |
-
happened so the app can say so at startup and a gate can assert it.
|
| 22 |
-
"""
|
| 23 |
-
import base64
|
| 24 |
-
import hmac
|
| 25 |
-
import json
|
| 26 |
-
import os
|
| 27 |
-
import secrets
|
| 28 |
-
import time
|
| 29 |
-
from hashlib import sha256
|
| 30 |
-
|
| 31 |
-
COOKIE_NAME = "aios_session"
|
| 32 |
-
IDLE_SECONDS = 8 * 60 * 60 # D2: reissued on every authenticated request
|
| 33 |
-
ABSOLUTE_SECONDS = 30 * 24 * 60 * 60 # D2: a hard ceiling no amount of activity extends
|
| 34 |
-
|
| 35 |
-
_env_secret = os.environ.get("AIOS_SESSION_SECRET") or ""
|
| 36 |
-
EPHEMERAL_SECRET = not _env_secret
|
| 37 |
-
_SECRET = _env_secret.encode("utf-8") if _env_secret else secrets.token_bytes(32)
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
def _b64(raw: bytes) -> str:
|
| 41 |
-
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
def _unb64(txt: str) -> bytes:
|
| 45 |
-
return base64.urlsafe_b64decode(txt + "=" * (-len(txt) % 4))
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
def _sign(payload: bytes) -> str:
|
| 49 |
-
return _b64(hmac.new(_SECRET, payload, sha256).digest())
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
def mint(tenant, username, epoch, now=None, absolute_expiry=None):
|
| 53 |
-
"""A fresh cookie value. `absolute_expiry` carries over on reissue so refreshing the idle
|
| 54 |
-
window can never extend the 30-day ceiling β that would make the ceiling unreachable."""
|
| 55 |
-
now = int(now if now is not None else time.time())
|
| 56 |
-
claims = {"t": str(tenant), "u": str(username), "e": int(epoch),
|
| 57 |
-
"i": now + IDLE_SECONDS,
|
| 58 |
-
"x": int(absolute_expiry if absolute_expiry is not None
|
| 59 |
-
else now + ABSOLUTE_SECONDS)}
|
| 60 |
-
payload = json.dumps(claims, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
| 61 |
-
return f"{_b64(payload)}.{_sign(payload)}", claims
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
def read(value, now=None):
|
| 65 |
-
"""Parse + verify a cookie value β claims dict, or None.
|
| 66 |
-
|
| 67 |
-
None for EVERY failure mode β bad shape, bad signature, expired, unparseable. The caller
|
| 68 |
-
answers 401 and must not learn which: an error that distinguishes "signature wrong" from
|
| 69 |
-
"expired" tells a forger whether their key is right.
|
| 70 |
-
"""
|
| 71 |
-
if not value or not isinstance(value, str) or value.count(".") != 1:
|
| 72 |
-
return None
|
| 73 |
-
body, sig = value.split(".", 1)
|
| 74 |
-
try:
|
| 75 |
-
payload = _unb64(body)
|
| 76 |
-
except Exception:
|
| 77 |
-
return None
|
| 78 |
-
# compare_digest on the SIGNATURE, always β a byte-wise early exit is a timing oracle.
|
| 79 |
-
if not hmac.compare_digest(sig, _sign(payload)):
|
| 80 |
-
return None
|
| 81 |
-
try:
|
| 82 |
-
claims = json.loads(payload.decode("utf-8"))
|
| 83 |
-
except Exception:
|
| 84 |
-
return None
|
| 85 |
-
if not isinstance(claims, dict):
|
| 86 |
-
return None
|
| 87 |
-
if not isinstance(claims.get("u"), str) or not isinstance(claims.get("t"), str):
|
| 88 |
-
return None
|
| 89 |
-
if not claims["u"] or not claims["t"]:
|
| 90 |
-
return None
|
| 91 |
-
try:
|
| 92 |
-
idle, absolute, epoch = int(claims["i"]), int(claims["x"]), int(claims["e"])
|
| 93 |
-
except (KeyError, TypeError, ValueError):
|
| 94 |
-
return None
|
| 95 |
-
now = int(now if now is not None else time.time())
|
| 96 |
-
if now >= idle or now >= absolute:
|
| 97 |
-
return None
|
| 98 |
-
claims["e"], claims["i"], claims["x"] = epoch, idle, absolute
|
| 99 |
-
return claims
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
def is_secure_request(request):
|
| 103 |
-
"""Secure flag ON unless this is plain-HTTP localhost. Set unconditionally in production;
|
| 104 |
-
unset on a local http:// dev origin, where a Secure cookie is simply never sent back and
|
| 105 |
-
login would appear to succeed and then not work."""
|
| 106 |
-
host = (request.headers.get("host") or "").split(":")[0].lower()
|
| 107 |
-
proto = (request.headers.get("x-forwarded-proto")
|
| 108 |
-
or request.url.scheme or "http").split(",")[0].strip().lower()
|
| 109 |
-
if proto == "https":
|
| 110 |
-
return True
|
| 111 |
-
return host not in ("localhost", "127.0.0.1", "[::1]", "::1")
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
def set_cookie(response, request, value):
|
| 115 |
-
response.set_cookie(
|
| 116 |
-
COOKIE_NAME, value, httponly=True, samesite="lax",
|
| 117 |
-
secure=is_secure_request(request), max_age=ABSOLUTE_SECONDS, path="/")
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
def clear_cookie(response, request):
|
| 121 |
-
# Same attributes as when it was set: a browser matches on path/secure/samesite when
|
| 122 |
-
# deleting, and a mismatched delete silently leaves the cookie in place.
|
| 123 |
-
response.delete_cookie(COOKIE_NAME, path="/", httponly=True, samesite="lax",
|
| 124 |
-
secure=is_secure_request(request))
|
|
|
|
| 1 |
+
"""aios_session.py β X3: the signed, STATELESS session cookie (EXIT-3a, 2026-07-30).
|
| 2 |
+
|
| 3 |
+
aios_session = base64url(json {t,u,e,i,x}) . base64url(HMAC-SHA256)
|
| 4 |
+
|
| 5 |
+
`t` tenant slug Β· `u` username Β· `e` the user's revocation epoch Β· `i` idle deadline Β·
|
| 6 |
+
`x` absolute expiry. Signed with `AIOS_SESSION_SECRET` using stdlib `hmac` β no new dependency,
|
| 7 |
+
and nothing to store server-side, which is the whole point: EXIT-4 requires a process that holds
|
| 8 |
+
no per-tenant state, and a session TABLE is per-tenant state. D2's Postgres sessions-MIRROR (an
|
| 9 |
+
audit view, not the source of truth) arrives with C-2 and is deliberately deferred.
|
| 10 |
+
|
| 11 |
+
WHY STATELESS STILL REVOKES. A cookie nobody can delete sounds like a cookie nobody can revoke.
|
| 12 |
+
The answer is the `epoch` integer on the user record (`core.users.epoch` / `bump_epoch`): the
|
| 13 |
+
cookie carries the epoch it was minted under, every verification re-reads the account's current
|
| 14 |
+
epoch, and a password change, a deactivation, or (wave 40, R2) any LOGIN bumps it β so every outstanding cookie for that
|
| 15 |
+
user dies at once, without a session table.
|
| 16 |
+
|
| 17 |
+
β NO FALLBACK SECRET, EVER. With `AIOS_SESSION_SECRET` unset this module mints a RANDOM
|
| 18 |
+
per-process key: sessions then die on restart, which is an inconvenience. A hardcoded default
|
| 19 |
+
would instead let anyone who has read this repo forge a cookie for any user of any tenant β an
|
| 20 |
+
inconvenience is the correct trade against a forgery key. `EPHEMERAL_SECRET` records which
|
| 21 |
+
happened so the app can say so at startup and a gate can assert it.
|
| 22 |
+
"""
|
| 23 |
+
import base64
|
| 24 |
+
import hmac
|
| 25 |
+
import json
|
| 26 |
+
import os
|
| 27 |
+
import secrets
|
| 28 |
+
import time
|
| 29 |
+
from hashlib import sha256
|
| 30 |
+
|
| 31 |
+
COOKIE_NAME = "aios_session"
|
| 32 |
+
IDLE_SECONDS = 8 * 60 * 60 # D2: reissued on every authenticated request
|
| 33 |
+
ABSOLUTE_SECONDS = 30 * 24 * 60 * 60 # D2: a hard ceiling no amount of activity extends
|
| 34 |
+
|
| 35 |
+
_env_secret = os.environ.get("AIOS_SESSION_SECRET") or ""
|
| 36 |
+
EPHEMERAL_SECRET = not _env_secret
|
| 37 |
+
_SECRET = _env_secret.encode("utf-8") if _env_secret else secrets.token_bytes(32)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _b64(raw: bytes) -> str:
|
| 41 |
+
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _unb64(txt: str) -> bytes:
|
| 45 |
+
return base64.urlsafe_b64decode(txt + "=" * (-len(txt) % 4))
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _sign(payload: bytes) -> str:
|
| 49 |
+
return _b64(hmac.new(_SECRET, payload, sha256).digest())
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def mint(tenant, username, epoch, now=None, absolute_expiry=None):
|
| 53 |
+
"""A fresh cookie value. `absolute_expiry` carries over on reissue so refreshing the idle
|
| 54 |
+
window can never extend the 30-day ceiling β that would make the ceiling unreachable."""
|
| 55 |
+
now = int(now if now is not None else time.time())
|
| 56 |
+
claims = {"t": str(tenant), "u": str(username), "e": int(epoch),
|
| 57 |
+
"i": now + IDLE_SECONDS,
|
| 58 |
+
"x": int(absolute_expiry if absolute_expiry is not None
|
| 59 |
+
else now + ABSOLUTE_SECONDS)}
|
| 60 |
+
payload = json.dumps(claims, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
| 61 |
+
return f"{_b64(payload)}.{_sign(payload)}", claims
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def read(value, now=None):
|
| 65 |
+
"""Parse + verify a cookie value β claims dict, or None.
|
| 66 |
+
|
| 67 |
+
None for EVERY failure mode β bad shape, bad signature, expired, unparseable. The caller
|
| 68 |
+
answers 401 and must not learn which: an error that distinguishes "signature wrong" from
|
| 69 |
+
"expired" tells a forger whether their key is right.
|
| 70 |
+
"""
|
| 71 |
+
if not value or not isinstance(value, str) or value.count(".") != 1:
|
| 72 |
+
return None
|
| 73 |
+
body, sig = value.split(".", 1)
|
| 74 |
+
try:
|
| 75 |
+
payload = _unb64(body)
|
| 76 |
+
except Exception:
|
| 77 |
+
return None
|
| 78 |
+
# compare_digest on the SIGNATURE, always β a byte-wise early exit is a timing oracle.
|
| 79 |
+
if not hmac.compare_digest(sig, _sign(payload)):
|
| 80 |
+
return None
|
| 81 |
+
try:
|
| 82 |
+
claims = json.loads(payload.decode("utf-8"))
|
| 83 |
+
except Exception:
|
| 84 |
+
return None
|
| 85 |
+
if not isinstance(claims, dict):
|
| 86 |
+
return None
|
| 87 |
+
if not isinstance(claims.get("u"), str) or not isinstance(claims.get("t"), str):
|
| 88 |
+
return None
|
| 89 |
+
if not claims["u"] or not claims["t"]:
|
| 90 |
+
return None
|
| 91 |
+
try:
|
| 92 |
+
idle, absolute, epoch = int(claims["i"]), int(claims["x"]), int(claims["e"])
|
| 93 |
+
except (KeyError, TypeError, ValueError):
|
| 94 |
+
return None
|
| 95 |
+
now = int(now if now is not None else time.time())
|
| 96 |
+
if now >= idle or now >= absolute:
|
| 97 |
+
return None
|
| 98 |
+
claims["e"], claims["i"], claims["x"] = epoch, idle, absolute
|
| 99 |
+
return claims
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def is_secure_request(request):
|
| 103 |
+
"""Secure flag ON unless this is plain-HTTP localhost. Set unconditionally in production;
|
| 104 |
+
unset on a local http:// dev origin, where a Secure cookie is simply never sent back and
|
| 105 |
+
login would appear to succeed and then not work."""
|
| 106 |
+
host = (request.headers.get("host") or "").split(":")[0].lower()
|
| 107 |
+
proto = (request.headers.get("x-forwarded-proto")
|
| 108 |
+
or request.url.scheme or "http").split(",")[0].strip().lower()
|
| 109 |
+
if proto == "https":
|
| 110 |
+
return True
|
| 111 |
+
return host not in ("localhost", "127.0.0.1", "[::1]", "::1")
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def set_cookie(response, request, value):
|
| 115 |
+
response.set_cookie(
|
| 116 |
+
COOKIE_NAME, value, httponly=True, samesite="lax",
|
| 117 |
+
secure=is_secure_request(request), max_age=ABSOLUTE_SECONDS, path="/")
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def clear_cookie(response, request):
|
| 121 |
+
# Same attributes as when it was set: a browser matches on path/secure/samesite when
|
| 122 |
+
# deleting, and a mismatched delete silently leaves the cookie in place.
|
| 123 |
+
response.delete_cookie(COOKIE_NAME, path="/", httponly=True, samesite="lax",
|
| 124 |
+
secure=is_secure_request(request))
|
api/deps.py
CHANGED
|
@@ -296,7 +296,20 @@ def assistant_read_scope(session: Session, database, fields=None, filters=None):
|
|
| 296 |
perm_scope.validate_assistant_filter((grant or {}).get("filter"), canonical_fields)
|
| 297 |
except ValueError as exc:
|
| 298 |
_assistant_refuse(400, "assistant_bad_filter", str(exc))
|
| 299 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 300 |
if grant is not None else list(rows or ()))
|
| 301 |
visible = (perm_scope.assistant_visible_fields(canonical_fields, session.user, key)
|
| 302 |
if grant is not None else list(canonical_fields))
|
|
|
|
| 296 |
perm_scope.validate_assistant_filter((grant or {}).get("filter"), canonical_fields)
|
| 297 |
except ValueError as exc:
|
| 298 |
_assistant_refuse(400, "assistant_bad_filter", str(exc))
|
| 299 |
+
# β OWNER I16 β the tenant handle rides into the row wall here too, so one stored rule means
|
| 300 |
+
# the same thing in the Analyst's answer as it does on the grid (see
|
| 301 |
+
# `perm_scope._enrich_for_wall`).
|
| 302 |
+
#
|
| 303 |
+
# β AND IT CHANGES NOTHING ON THIS PATH TODAY, WHICH IS STATED RATHER THAN LEFT TO BE
|
| 304 |
+
# REDISCOVERED. `validate_assistant_filter` three lines up refuses any leaf naming a column
|
| 305 |
+
# outside `canonical_fields`, so a wall on a user-generated column is rejected before it can
|
| 306 |
+
# reach this call at all. The handle is passed anyway because the alternative is a door that
|
| 307 |
+
# walls these modules WITHOUT it β and "all six or none" is the property that keeps one wall
|
| 308 |
+
# from having two meanings. Widening the Assistant's own validator to admit such a column is
|
| 309 |
+
# a separate decision about what E may read, not a side effect of a grid fix.
|
| 310 |
+
permitted_rows = (perm_scope.assistant_apply_row_scope(rows, session.user, key,
|
| 311 |
+
canonical_fields,
|
| 312 |
+
st=getattr(session, "runtime", None))
|
| 313 |
if grant is not None else list(rows or ()))
|
| 314 |
visible = (perm_scope.assistant_visible_fields(canonical_fields, session.user, key)
|
| 315 |
if grant is not None else list(canonical_fields))
|
api/routes_admin.py
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
api/routes_customers.py
CHANGED
|
@@ -274,7 +274,21 @@ def grid_assembly(session: Session, scope: str = "customer", storage_key: str =
|
|
| 274 |
# reasons: the assembled list is not built yet (it needs `pids`), and a permanent filter may
|
| 275 |
# only ever name a canonical field anyway β `routes_admin._clean_perms` validates it against
|
| 276 |
# exactly this schema and 400s otherwise. `permits()` denies on anything it cannot answer.
|
| 277 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
pids = frozenset(r["pid"] for r in rows_src if r.get("pid") is not None)
|
| 279 |
# β W38-T20 β THE COLUMN DEFINITIONS ARE READ **ONCE** PER ASSEMBLY AND THREADED, because
|
| 280 |
# `core.store.get` deep-copies whatever it hands back on every call. Three consumers want this
|
|
@@ -488,12 +502,17 @@ def allowed_pids(session: Session):
|
|
| 488 |
paths close that gap with `apply_row_scope`; without the same call here the write wall would
|
| 489 |
be the wider set, and a restricted user could PATCH a row this API will not show them.
|
| 490 |
Same function, same order as `grid_assembly`, so the two walls cannot drift.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 491 |
"""
|
| 492 |
import core.perm_scope as perm_scope
|
| 493 |
import aios_grid
|
| 494 |
|
| 495 |
rows = perm_scope.apply_row_scope(_pool_rows(session), session.user, MODULE,
|
| 496 |
-
aios_grid.FIELDS)
|
| 497 |
return frozenset(r["pid"] for r in rows if r.get("pid") is not None)
|
| 498 |
|
| 499 |
|
|
|
|
| 274 |
# reasons: the assembled list is not built yet (it needs `pids`), and a permanent filter may
|
| 275 |
# only ever name a canonical field anyway β `routes_admin._clean_perms` validates it against
|
| 276 |
# exactly this schema and 400s otherwise. `permits()` denies on anything it cannot answer.
|
| 277 |
+
#
|
| 278 |
+
# ββ OWNER I16 β `st=rt` IS WHAT MAKES A WALL ON A USER-GENERATED COLUMN MEAN ANYTHING
|
| 279 |
+
# HERE. *"Permission Filters must be able to filter on user-generated Fields too."* The
|
| 280 |
+
# sentence above is exactly why it was needed: the canonical list has no `custom_` column in
|
| 281 |
+
# it and these rows are PRE-OVERLAY, so such a leaf denied every row while the editor
|
| 282 |
+
# reported the rule saved. With the handle, `perm_scope._enrich_for_wall` merges the
|
| 283 |
+
# tenant-wide value for the named column onto a COPY of each row and declares it for the
|
| 284 |
+
# evaluator. Nothing else about this call changes, and a caller with no handle still gets
|
| 285 |
+
# the wall exactly as it was.
|
| 286 |
+
#
|
| 287 |
+
# β THE SAME HANDLE GOES TO `allowed_pids` BELOW, AND THE PAIR IS NOT OPTIONAL. That is the
|
| 288 |
+
# WRITE wall to this one's READ wall; lending it here alone would make a user-generated rule
|
| 289 |
+
# narrow what an account SEES while leaving what it may PATCH untouched.
|
| 290 |
+
rows_src = perm_scope.apply_row_scope(rows_src, session.user, MODULE, aios_grid.FIELDS,
|
| 291 |
+
st=rt)
|
| 292 |
pids = frozenset(r["pid"] for r in rows_src if r.get("pid") is not None)
|
| 293 |
# β W38-T20 β THE COLUMN DEFINITIONS ARE READ **ONCE** PER ASSEMBLY AND THREADED, because
|
| 294 |
# `core.store.get` deep-copies whatever it hands back on every call. Three consumers want this
|
|
|
|
| 502 |
paths close that gap with `apply_row_scope`; without the same call here the write wall would
|
| 503 |
be the wider set, and a restricted user could PATCH a row this API will not show them.
|
| 504 |
Same function, same order as `grid_assembly`, so the two walls cannot drift.
|
| 505 |
+
|
| 506 |
+
β AND `st` IS PART OF "SAME FUNCTION, SAME ORDER" (owner I16). `grid_assembly` lends the
|
| 507 |
+
tenant handle so a wall naming a user-generated column can be answered at all; without the
|
| 508 |
+
identical argument here that rule would narrow the READ and not the WRITE β the drift this
|
| 509 |
+
docstring already refuses, wearing a new argument's clothes.
|
| 510 |
"""
|
| 511 |
import core.perm_scope as perm_scope
|
| 512 |
import aios_grid
|
| 513 |
|
| 514 |
rows = perm_scope.apply_row_scope(_pool_rows(session), session.user, MODULE,
|
| 515 |
+
aios_grid.FIELDS, st=session.runtime)
|
| 516 |
return frozenset(r["pid"] for r in rows if r.get("pid") is not None)
|
| 517 |
|
| 518 |
|
api/routes_products.py
CHANGED
|
@@ -115,7 +115,14 @@ def scoped_pool(session: Session):
|
|
| 115 |
# The SAME wall the customer assembly applies, in the same order: rows first (before pids
|
| 116 |
# are taken, so an out-of-filter row never enters allowed_pids), then the field closure,
|
| 117 |
# then the values stripped from the rows as well as the field list.
|
| 118 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
pids = frozenset(r["pid"] for r in rows_src if r.get("pid") is not None)
|
| 120 |
return pids, team_id, rows_src, fields_base
|
| 121 |
|
|
|
|
| 115 |
# The SAME wall the customer assembly applies, in the same order: rows first (before pids
|
| 116 |
# are taken, so an out-of-filter row never enters allowed_pids), then the field closure,
|
| 117 |
# then the values stripped from the rows as well as the field list.
|
| 118 |
+
# ββ OWNER I16 β AND THE SAME TENANT HANDLE, for the same reason and with the same
|
| 119 |
+
# consequence if it is left off. `fields_base` is this topic's contract plus its declared
|
| 120 |
+
# tenant-wide columns; a column a USER made is in neither, and these rows are pre-overlay,
|
| 121 |
+
# so such a leaf denied every product row while the admin was told the rule saved. See
|
| 122 |
+
# `perm_scope._enrich_for_wall`. This route is BOTH walls at once (it returns the pids the
|
| 123 |
+
# write door uses), so there is no second call here to keep in step.
|
| 124 |
+
rows_src = perm_scope.apply_row_scope(rows_src, session.user, MODULE, fields_base,
|
| 125 |
+
st=session.runtime)
|
| 126 |
pids = frozenset(r["pid"] for r in rows_src if r.get("pid") is not None)
|
| 127 |
return pids, team_id, rows_src, fields_base
|
| 128 |
|
api/routes_slack.py
CHANGED
|
@@ -182,13 +182,30 @@ def agent_visible_fields(agent, fields, module):
|
|
| 182 |
return perm_scope.visible_fields(fields, agent_principal(agent), module)
|
| 183 |
|
| 184 |
|
| 185 |
-
def agent_rows(agent, rows, module, fields, ctx=None):
|
| 186 |
"""The rows this channel may receive β `permits()`, so an unanswerable permanent filter DENIES
|
| 187 |
-
rather than being ignored.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
import core.perm_scope as perm_scope
|
| 189 |
p = agent_principal(agent)
|
| 190 |
hide = perm_scope.hidden_keys(p, module, fields)
|
| 191 |
-
kept = perm_scope.apply_row_scope(rows, p, module, fields, ctx)
|
| 192 |
# Both wires, never one: the field list and the row payload are separate, and stripping only
|
| 193 |
# the first leaves the value sitting in the second where anyone can read it.
|
| 194 |
return [perm_scope.strip_row(r, hide) for r in kept]
|
|
|
|
| 182 |
return perm_scope.visible_fields(fields, agent_principal(agent), module)
|
| 183 |
|
| 184 |
|
| 185 |
+
def agent_rows(agent, rows, module, fields, ctx=None, st=None):
|
| 186 |
"""The rows this channel may receive β `permits()`, so an unanswerable permanent filter DENIES
|
| 187 |
+
rather than being ignored.
|
| 188 |
+
|
| 189 |
+
ββ `st` IS THE TENANT HANDLE AND IT IS HERE BECAUSE OF THE SENTENCE IN `agent_principal`:
|
| 190 |
+
a channel agent IS a principal of the same wall, so a permanent filter naming a
|
| 191 |
+
user-generated column has to mean the SAME thing here as it does on the grid (owner I16).
|
| 192 |
+
A door that lends the handle and a door that does not are one stored rule with two meanings.
|
| 193 |
+
|
| 194 |
+
β PASS THE TENANT'S OWN RUNTIME β every other store read in this file takes
|
| 195 |
+
`session.runtime`, and a channel is not a second tenancy. It is a keyword with a `None`
|
| 196 |
+
default for the same reason `perm_scope.apply_row_scope`'s is: without one this is
|
| 197 |
+
byte-identical to the wall that shipped before the argument existed.
|
| 198 |
+
|
| 199 |
+
β `hidden_keys` DELIBERATELY DOES NOT TAKE IT IN THIS CHANGE. Its field-grant leg
|
| 200 |
+
under-hides without a handle (see `perm_scope.visible_overlays`), which is a real and
|
| 201 |
+
separate gap on this surface β but it is the FIELD wall, it moves what a channel receives
|
| 202 |
+
rather than what I16 asked for, and widening two walls in one ticket is how a permission
|
| 203 |
+
change stops being reviewable.
|
| 204 |
+
"""
|
| 205 |
import core.perm_scope as perm_scope
|
| 206 |
p = agent_principal(agent)
|
| 207 |
hide = perm_scope.hidden_keys(p, module, fields)
|
| 208 |
+
kept = perm_scope.apply_row_scope(rows, p, module, fields, ctx, st=st)
|
| 209 |
# Both wires, never one: the field list and the row payload are separate, and stripping only
|
| 210 |
# the first leaves the value sitting in the second where anyone can read it.
|
| 211 |
return [perm_scope.strip_row(r, hide) for r in kept]
|
platform/core/perm_scope.py
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
platform/core/shared_overlay.py
CHANGED
|
@@ -1,302 +1,327 @@
|
|
| 1 |
-
"""The TENANT-WIDE overlay stratum β one value per (row, column) for the whole workspace.
|
| 2 |
-
|
| 3 |
-
Wave 29, item 20 / owner ruling R11, contract C5. The sibling of `core/table_store.py`, which
|
| 4 |
-
holds the PER-USER strata (`store.get(key)[username]`) and always has.
|
| 5 |
-
|
| 6 |
-
β THE DEFECT THIS EXISTS FOR IS NOT "SHARING WOULD BE NICE" β IT IS THAT A SHARED VIEW SILENTLY
|
| 7 |
-
WIDENS. `modules/product_data.py` already states it, in the comment above the supplier master:
|
| 8 |
-
|
| 9 |
-
"A user-created field and its values live in the PER-USER strata; only VIEWS are shared. So a
|
| 10 |
-
shared 'Buy list' view that filtered on a user-created column would, for every OTHER account,
|
| 11 |
-
name a column that does not exist β and an unknown column is an INACTIVE condition in the
|
| 12 |
-
tri-state engine, which IGNORES it and therefore WIDENS. The buy list would silently show the
|
| 13 |
-
whole catalogue to everyone but its author."
|
| 14 |
-
|
| 15 |
-
That is why the four supplier columns were frozen as read-only CONTRACT columns rather than made
|
| 16 |
-
editable, and why the owner's ask ("turn this Excel sheet into a User created Field that we can
|
| 17 |
-
edit") has been parked for two waves. A column whose value is the same for every reader makes the
|
| 18 |
-
shared view mean ONE thing, which is the precondition for editing it at all.
|
| 19 |
-
|
| 20 |
-
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 21 |
-
ββ THIS MODULE IS NOT A PERMISSION WALL, AND MUST NEVER BECOME ONE BY ACCIDENT.
|
| 22 |
-
|
| 23 |
-
It refuses no reader and no writer. The caller has already answered "may this session open this
|
| 24 |
-
surface" (`user_tables.may_open`, `Session.require`, the BU pool) and this module answers only
|
| 25 |
-
"what is stored". Two different questions; one of them belongs upstream, where the session is.
|
| 26 |
-
|
| 27 |
-
What it DOES enforce is the one thing a caller can get wrong silently:
|
| 28 |
-
|
| 29 |
-
β `cells(table_key, pids)` β `pids` IS REQUIRED, POSITIONALLY, AND THERE IS NO "EVERYTHING"
|
| 30 |
-
CALL. A default of "all rows" is the widening hazard above wearing a friendly face: the
|
| 31 |
-
caller passes the row set it has ALREADY scoped, so a cell for a row this reader may not see
|
| 32 |
-
cannot come back to be merged. There is deliberately no `all_cells()` to reach for; a caller
|
| 33 |
-
that genuinely needs the lot passes the lot, in writing, where a reviewer can see it.
|
| 34 |
-
|
| 35 |
-
The row wall then holds twice over, because `aios_grid.rows_from_pool` iterates the POOL and looks
|
| 36 |
-
each pid UP in the overlay β never the reverse. A cell for a row outside the pool has nothing to
|
| 37 |
-
attach to. This module is built to keep that true rather than to re-implement it.
|
| 38 |
-
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 39 |
-
|
| 40 |
-
RESIDENCY. Its own bucket, `<table_key>__shared`, beside the per-user one β never a `__shared__`
|
| 41 |
-
member inside it. Two reasons, and the first is `product_data.py`'s own argument for separate
|
| 42 |
-
topic keys ("separate store keys make that structurally impossible, which is the whole reason the
|
| 43 |
-
table-page factory exists"): a per-user reader iterating usernames cannot encounter shared data
|
| 44 |
-
when there is no shared data in that bucket to encounter. The second is cost β the per-user bucket
|
| 45 |
-
carries every user's views, and a cell edit should not read-modify-write all of it.
|
| 46 |
-
|
| 47 |
-
SHAPE, chosen to be drop-in:
|
| 48 |
-
|
| 49 |
-
{"fields": {field_key: Field}, # the tenant-wide column DEFINITIONS
|
| 50 |
-
"cells": {"<pid>": {field_key: value}}} # exactly `rows_from_pool(overlays=...)`'s shape
|
| 51 |
-
|
| 52 |
-
`cells()` returns STRING pid keys for that reason β `rows_from_pool` does `overlays.get(str(pid))`,
|
| 53 |
-
so a caller merges the shared stratum over the per-user one with one `dict.update` and cannot get
|
| 54 |
-
the key type wrong. (`table_store` stores `{str(pid): {...}}` too; one shape, three readers.)
|
| 55 |
-
"""
|
| 56 |
-
import core.store as store
|
| 57 |
-
|
| 58 |
-
#: The suffix that turns a topic's per-user workspace key into its shared one. Callers pass the
|
| 59 |
-
#: key they ALREADY hold (`product_data.TABLE_KEY`, `f'{ut_key}_table_workspace'`) β one
|
| 60 |
-
#: identifier for both strata, so there is no second naming convention to get wrong.
|
| 61 |
-
BUCKET_SUFFIX = '__shared'
|
| 62 |
-
|
| 63 |
-
#: One cell value's ceiling, matching the `json` field kind's. A shared cell is read by every user
|
| 64 |
-
#: in the tenant, so an unbounded one is an unbounded cost for all of them.
|
| 65 |
-
MAX_VALUE_CHARS = 32_768
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
def bucket(table_key):
|
| 69 |
-
"""The store key this topic's shared stratum lives under."""
|
| 70 |
-
key = str(table_key or '').strip()
|
| 71 |
-
if not key:
|
| 72 |
-
raise ValueError('shared_overlay: a table_key is required β it names the bucket')
|
| 73 |
-
return f'{key}{BUCKET_SUFFIX}'
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
def _st(st):
|
| 77 |
-
return st if st is not None else store
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
def _read(table_key, st=None):
|
| 81 |
-
"""The whole stratum, always in its full two-key shape. Lenient like every other display
|
| 82 |
-
read: an unreachable store degrades to "nothing shared yet", never to an exception on a page
|
| 83 |
-
that would otherwise render."""
|
| 84 |
-
try:
|
| 85 |
-
data = _st(st).get(bucket(table_key)) or {}
|
| 86 |
-
except Exception:
|
| 87 |
-
data = {}
|
| 88 |
-
return {'fields': dict(data.get('fields') or {}),
|
| 89 |
-
'cells': dict(data.get('cells') or {})}
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
def _write(table_key, change, st=None, flush='async'):
|
| 93 |
-
"""Read-modify-write one shared stratum.
|
| 94 |
-
|
| 95 |
-
`flush='async'` by default for the same reason `table_store._update` uses it: a typed cell
|
| 96 |
-
lands here inside the request round-trip, and the historical synchronous hub commit cost
|
| 97 |
-
seconds per keystroke. Structural writes (a field definition) pass `flush='sync'` β they are
|
| 98 |
-
rare, and a lost column definition is a worse failure than a lost keystroke.
|
| 99 |
-
"""
|
| 100 |
-
def _up(data):
|
| 101 |
-
data = data if isinstance(data, dict) else {}
|
| 102 |
-
data.setdefault('fields', {})
|
| 103 |
-
data.setdefault('cells', {})
|
| 104 |
-
change(data)
|
| 105 |
-
return data
|
| 106 |
-
return _st(st).update(bucket(table_key), _up, flush=flush)
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
def _pid(pid):
|
| 110 |
-
"""Pids are ints everywhere in the grid and strings in JSON. ONE coercion, here, so a caller
|
| 111 |
-
passing either cannot write a row that a reader keyed the other way never finds."""
|
| 112 |
-
return str(int(pid))
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
def _value(value):
|
| 116 |
-
"""A cell holds a SCALAR. The Row contract is scalar on every surface β the grid, the filter
|
| 117 |
-
engine, formulas, export β and the `json` kind is a validated STRING rather than an object.
|
| 118 |
-
|
| 119 |
-
β A dict or a list RAISES rather than being dropped. This is called by our own code, so a
|
| 120 |
-
non-scalar is a programming error, and silently storing nothing would surface later as "the
|
| 121 |
-
shared column is blank for everyone" with no failure anywhere near the cause.
|
| 122 |
-
"""
|
| 123 |
-
if isinstance(value, (dict, list, tuple, set)):
|
| 124 |
-
raise ValueError(f'shared_overlay: a cell holds a scalar, not {type(value).__name__}')
|
| 125 |
-
if value is None or isinstance(value, bool) or isinstance(value, (int, float)):
|
| 126 |
-
return value
|
| 127 |
-
text = str(value)
|
| 128 |
-
return text[:MAX_VALUE_CHARS]
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
# ----------------------------------------------------------------- the column DEFINITIONS
|
| 132 |
-
def fields(table_key, st=None):
|
| 133 |
-
"""`{field_key: Field}` β the columns this topic shares tenant-wide.
|
| 134 |
-
|
| 135 |
-
Unscoped on purpose, and it is the one thing here that is: a shared column's EXISTENCE is
|
| 136 |
-
tenant-wide by definition β that is the whole feature, and it is what stops a shared view
|
| 137 |
-
naming a column half the workspace lacks. Its VALUES are scoped by `cells()`.
|
| 138 |
-
"""
|
| 139 |
-
return _read(table_key, st)['fields']
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
def is_shared(table_key, field_key, st=None):
|
| 143 |
-
"""Is this column's value tenant-wide? The predicate a caller uses to decide WHICH stratum to
|
| 144 |
-
read and write β one evaluator, so the read path and the write path cannot disagree about
|
| 145 |
-
where a given column lives ([[one-evaluator-per-question]])."""
|
| 146 |
-
return str(field_key or '') in _read(table_key, st)['fields']
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
def put_field(table_key, field_key, defn, st=None):
|
| 150 |
-
"""Declare (or redefine) a shared column. Returns what was stored."""
|
| 151 |
-
key = str(field_key or '').strip()
|
| 152 |
-
if not key:
|
| 153 |
-
raise ValueError('shared_overlay: a field needs a key')
|
| 154 |
-
entry = dict(defn or {})
|
| 155 |
-
entry['key'] = key
|
| 156 |
-
|
| 157 |
-
def _add(data):
|
| 158 |
-
data['fields'][key] = entry
|
| 159 |
-
|
| 160 |
-
_write(table_key, _add, st, flush='sync')
|
| 161 |
-
return entry
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
def drop_field(table_key, field_key, st=None):
|
| 165 |
-
"""Remove a shared column AND every stored value for it.
|
| 166 |
-
|
| 167 |
-
β The cells go with the definition, exactly as `table_store.delete_field` scrubs its own:
|
| 168 |
-
orphaned values would silently resurface if the key were ever reused β and a resurrected
|
| 169 |
-
value in a TENANT-WIDE stratum reappears for everybody at once.
|
| 170 |
-
"""
|
| 171 |
-
key = str(field_key or '').strip()
|
| 172 |
-
if not key:
|
| 173 |
-
return False
|
| 174 |
-
hit = [False]
|
| 175 |
-
|
| 176 |
-
def _drop(data):
|
| 177 |
-
hit[0] = data['fields'].pop(key, None) is not None
|
| 178 |
-
for row in data['cells'].values():
|
| 179 |
-
if isinstance(row, dict):
|
| 180 |
-
row.pop(key, None)
|
| 181 |
-
# β AND DROP THE ROWS THAT ARE NOW EMPTY. `cells()` already skips a blank row, so this
|
| 182 |
-
# changes nothing a reader sees β but this bucket is read by every user in the tenant,
|
| 183 |
-
# and a `{}` per pid that once held a since-deleted column is dead weight that only ever
|
| 184 |
-
# grows. Caught by `prune()` reporting rows nobody could see as dropped.
|
| 185 |
-
for pid in [p for p, row in data['cells'].items() if not row]:
|
| 186 |
-
data['cells'].pop(pid, None)
|
| 187 |
-
|
| 188 |
-
_write(table_key, _drop, st, flush='sync')
|
| 189 |
-
return hit[0]
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
# ----------------------------------------------------------------------------- the VALUES
|
| 193 |
-
def cells(table_key, pids, st=None):
|
| 194 |
-
"""`{"<pid>": {field_key: value}}` for the rows named by `pids` β and ONLY those.
|
| 195 |
-
|
| 196 |
-
β `pids` IS REQUIRED. See the module header: this is the row wall expressed as a signature,
|
| 197 |
-
so a caller cannot get "every shared cell in the tenant" by forgetting an argument. Pass the
|
| 198 |
-
pool you have already scoped for this session.
|
| 199 |
-
|
| 200 |
-
An empty `pids` legitimately returns `{}` β a reader with no rows sees no cells, which is the
|
| 201 |
-
correct answer rather than a special case.
|
| 202 |
-
"""
|
| 203 |
-
if pids is None:
|
| 204 |
-
raise TypeError('shared_overlay.cells: pids is required β pass the row set you have '
|
| 205 |
-
'already scoped for this reader (there is deliberately no "all" call)')
|
| 206 |
-
wanted = {_pid(p) for p in pids}
|
| 207 |
-
if not wanted:
|
| 208 |
-
return {}
|
| 209 |
-
stored = _read(table_key, st)['cells']
|
| 210 |
-
return {pid: dict(row) for pid, row in stored.items()
|
| 211 |
-
if pid in wanted and isinstance(row, dict) and row}
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
def
|
| 215 |
-
"""
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The TENANT-WIDE overlay stratum β one value per (row, column) for the whole workspace.
|
| 2 |
+
|
| 3 |
+
Wave 29, item 20 / owner ruling R11, contract C5. The sibling of `core/table_store.py`, which
|
| 4 |
+
holds the PER-USER strata (`store.get(key)[username]`) and always has.
|
| 5 |
+
|
| 6 |
+
β THE DEFECT THIS EXISTS FOR IS NOT "SHARING WOULD BE NICE" β IT IS THAT A SHARED VIEW SILENTLY
|
| 7 |
+
WIDENS. `modules/product_data.py` already states it, in the comment above the supplier master:
|
| 8 |
+
|
| 9 |
+
"A user-created field and its values live in the PER-USER strata; only VIEWS are shared. So a
|
| 10 |
+
shared 'Buy list' view that filtered on a user-created column would, for every OTHER account,
|
| 11 |
+
name a column that does not exist β and an unknown column is an INACTIVE condition in the
|
| 12 |
+
tri-state engine, which IGNORES it and therefore WIDENS. The buy list would silently show the
|
| 13 |
+
whole catalogue to everyone but its author."
|
| 14 |
+
|
| 15 |
+
That is why the four supplier columns were frozen as read-only CONTRACT columns rather than made
|
| 16 |
+
editable, and why the owner's ask ("turn this Excel sheet into a User created Field that we can
|
| 17 |
+
edit") has been parked for two waves. A column whose value is the same for every reader makes the
|
| 18 |
+
shared view mean ONE thing, which is the precondition for editing it at all.
|
| 19 |
+
|
| 20 |
+
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 21 |
+
ββ THIS MODULE IS NOT A PERMISSION WALL, AND MUST NEVER BECOME ONE BY ACCIDENT.
|
| 22 |
+
|
| 23 |
+
It refuses no reader and no writer. The caller has already answered "may this session open this
|
| 24 |
+
surface" (`user_tables.may_open`, `Session.require`, the BU pool) and this module answers only
|
| 25 |
+
"what is stored". Two different questions; one of them belongs upstream, where the session is.
|
| 26 |
+
|
| 27 |
+
What it DOES enforce is the one thing a caller can get wrong silently:
|
| 28 |
+
|
| 29 |
+
β `cells(table_key, pids)` β `pids` IS REQUIRED, POSITIONALLY, AND THERE IS NO "EVERYTHING"
|
| 30 |
+
CALL. A default of "all rows" is the widening hazard above wearing a friendly face: the
|
| 31 |
+
caller passes the row set it has ALREADY scoped, so a cell for a row this reader may not see
|
| 32 |
+
cannot come back to be merged. There is deliberately no `all_cells()` to reach for; a caller
|
| 33 |
+
that genuinely needs the lot passes the lot, in writing, where a reviewer can see it.
|
| 34 |
+
|
| 35 |
+
The row wall then holds twice over, because `aios_grid.rows_from_pool` iterates the POOL and looks
|
| 36 |
+
each pid UP in the overlay β never the reverse. A cell for a row outside the pool has nothing to
|
| 37 |
+
attach to. This module is built to keep that true rather than to re-implement it.
|
| 38 |
+
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 39 |
+
|
| 40 |
+
RESIDENCY. Its own bucket, `<table_key>__shared`, beside the per-user one β never a `__shared__`
|
| 41 |
+
member inside it. Two reasons, and the first is `product_data.py`'s own argument for separate
|
| 42 |
+
topic keys ("separate store keys make that structurally impossible, which is the whole reason the
|
| 43 |
+
table-page factory exists"): a per-user reader iterating usernames cannot encounter shared data
|
| 44 |
+
when there is no shared data in that bucket to encounter. The second is cost β the per-user bucket
|
| 45 |
+
carries every user's views, and a cell edit should not read-modify-write all of it.
|
| 46 |
+
|
| 47 |
+
SHAPE, chosen to be drop-in:
|
| 48 |
+
|
| 49 |
+
{"fields": {field_key: Field}, # the tenant-wide column DEFINITIONS
|
| 50 |
+
"cells": {"<pid>": {field_key: value}}} # exactly `rows_from_pool(overlays=...)`'s shape
|
| 51 |
+
|
| 52 |
+
`cells()` returns STRING pid keys for that reason β `rows_from_pool` does `overlays.get(str(pid))`,
|
| 53 |
+
so a caller merges the shared stratum over the per-user one with one `dict.update` and cannot get
|
| 54 |
+
the key type wrong. (`table_store` stores `{str(pid): {...}}` too; one shape, three readers.)
|
| 55 |
+
"""
|
| 56 |
+
import core.store as store
|
| 57 |
+
|
| 58 |
+
#: The suffix that turns a topic's per-user workspace key into its shared one. Callers pass the
|
| 59 |
+
#: key they ALREADY hold (`product_data.TABLE_KEY`, `f'{ut_key}_table_workspace'`) β one
|
| 60 |
+
#: identifier for both strata, so there is no second naming convention to get wrong.
|
| 61 |
+
BUCKET_SUFFIX = '__shared'
|
| 62 |
+
|
| 63 |
+
#: One cell value's ceiling, matching the `json` field kind's. A shared cell is read by every user
|
| 64 |
+
#: in the tenant, so an unbounded one is an unbounded cost for all of them.
|
| 65 |
+
MAX_VALUE_CHARS = 32_768
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def bucket(table_key):
|
| 69 |
+
"""The store key this topic's shared stratum lives under."""
|
| 70 |
+
key = str(table_key or '').strip()
|
| 71 |
+
if not key:
|
| 72 |
+
raise ValueError('shared_overlay: a table_key is required β it names the bucket')
|
| 73 |
+
return f'{key}{BUCKET_SUFFIX}'
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _st(st):
|
| 77 |
+
return st if st is not None else store
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def _read(table_key, st=None):
|
| 81 |
+
"""The whole stratum, always in its full two-key shape. Lenient like every other display
|
| 82 |
+
read: an unreachable store degrades to "nothing shared yet", never to an exception on a page
|
| 83 |
+
that would otherwise render."""
|
| 84 |
+
try:
|
| 85 |
+
data = _st(st).get(bucket(table_key)) or {}
|
| 86 |
+
except Exception:
|
| 87 |
+
data = {}
|
| 88 |
+
return {'fields': dict(data.get('fields') or {}),
|
| 89 |
+
'cells': dict(data.get('cells') or {})}
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _write(table_key, change, st=None, flush='async'):
|
| 93 |
+
"""Read-modify-write one shared stratum.
|
| 94 |
+
|
| 95 |
+
`flush='async'` by default for the same reason `table_store._update` uses it: a typed cell
|
| 96 |
+
lands here inside the request round-trip, and the historical synchronous hub commit cost
|
| 97 |
+
seconds per keystroke. Structural writes (a field definition) pass `flush='sync'` β they are
|
| 98 |
+
rare, and a lost column definition is a worse failure than a lost keystroke.
|
| 99 |
+
"""
|
| 100 |
+
def _up(data):
|
| 101 |
+
data = data if isinstance(data, dict) else {}
|
| 102 |
+
data.setdefault('fields', {})
|
| 103 |
+
data.setdefault('cells', {})
|
| 104 |
+
change(data)
|
| 105 |
+
return data
|
| 106 |
+
return _st(st).update(bucket(table_key), _up, flush=flush)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def _pid(pid):
|
| 110 |
+
"""Pids are ints everywhere in the grid and strings in JSON. ONE coercion, here, so a caller
|
| 111 |
+
passing either cannot write a row that a reader keyed the other way never finds."""
|
| 112 |
+
return str(int(pid))
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def _value(value):
|
| 116 |
+
"""A cell holds a SCALAR. The Row contract is scalar on every surface β the grid, the filter
|
| 117 |
+
engine, formulas, export β and the `json` kind is a validated STRING rather than an object.
|
| 118 |
+
|
| 119 |
+
β A dict or a list RAISES rather than being dropped. This is called by our own code, so a
|
| 120 |
+
non-scalar is a programming error, and silently storing nothing would surface later as "the
|
| 121 |
+
shared column is blank for everyone" with no failure anywhere near the cause.
|
| 122 |
+
"""
|
| 123 |
+
if isinstance(value, (dict, list, tuple, set)):
|
| 124 |
+
raise ValueError(f'shared_overlay: a cell holds a scalar, not {type(value).__name__}')
|
| 125 |
+
if value is None or isinstance(value, bool) or isinstance(value, (int, float)):
|
| 126 |
+
return value
|
| 127 |
+
text = str(value)
|
| 128 |
+
return text[:MAX_VALUE_CHARS]
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
# ----------------------------------------------------------------- the column DEFINITIONS
|
| 132 |
+
def fields(table_key, st=None):
|
| 133 |
+
"""`{field_key: Field}` β the columns this topic shares tenant-wide.
|
| 134 |
+
|
| 135 |
+
Unscoped on purpose, and it is the one thing here that is: a shared column's EXISTENCE is
|
| 136 |
+
tenant-wide by definition β that is the whole feature, and it is what stops a shared view
|
| 137 |
+
naming a column half the workspace lacks. Its VALUES are scoped by `cells()`.
|
| 138 |
+
"""
|
| 139 |
+
return _read(table_key, st)['fields']
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def is_shared(table_key, field_key, st=None):
|
| 143 |
+
"""Is this column's value tenant-wide? The predicate a caller uses to decide WHICH stratum to
|
| 144 |
+
read and write β one evaluator, so the read path and the write path cannot disagree about
|
| 145 |
+
where a given column lives ([[one-evaluator-per-question]])."""
|
| 146 |
+
return str(field_key or '') in _read(table_key, st)['fields']
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def put_field(table_key, field_key, defn, st=None):
|
| 150 |
+
"""Declare (or redefine) a shared column. Returns what was stored."""
|
| 151 |
+
key = str(field_key or '').strip()
|
| 152 |
+
if not key:
|
| 153 |
+
raise ValueError('shared_overlay: a field needs a key')
|
| 154 |
+
entry = dict(defn or {})
|
| 155 |
+
entry['key'] = key
|
| 156 |
+
|
| 157 |
+
def _add(data):
|
| 158 |
+
data['fields'][key] = entry
|
| 159 |
+
|
| 160 |
+
_write(table_key, _add, st, flush='sync')
|
| 161 |
+
return entry
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def drop_field(table_key, field_key, st=None):
|
| 165 |
+
"""Remove a shared column AND every stored value for it.
|
| 166 |
+
|
| 167 |
+
β The cells go with the definition, exactly as `table_store.delete_field` scrubs its own:
|
| 168 |
+
orphaned values would silently resurface if the key were ever reused β and a resurrected
|
| 169 |
+
value in a TENANT-WIDE stratum reappears for everybody at once.
|
| 170 |
+
"""
|
| 171 |
+
key = str(field_key or '').strip()
|
| 172 |
+
if not key:
|
| 173 |
+
return False
|
| 174 |
+
hit = [False]
|
| 175 |
+
|
| 176 |
+
def _drop(data):
|
| 177 |
+
hit[0] = data['fields'].pop(key, None) is not None
|
| 178 |
+
for row in data['cells'].values():
|
| 179 |
+
if isinstance(row, dict):
|
| 180 |
+
row.pop(key, None)
|
| 181 |
+
# β AND DROP THE ROWS THAT ARE NOW EMPTY. `cells()` already skips a blank row, so this
|
| 182 |
+
# changes nothing a reader sees β but this bucket is read by every user in the tenant,
|
| 183 |
+
# and a `{}` per pid that once held a since-deleted column is dead weight that only ever
|
| 184 |
+
# grows. Caught by `prune()` reporting rows nobody could see as dropped.
|
| 185 |
+
for pid in [p for p, row in data['cells'].items() if not row]:
|
| 186 |
+
data['cells'].pop(pid, None)
|
| 187 |
+
|
| 188 |
+
_write(table_key, _drop, st, flush='sync')
|
| 189 |
+
return hit[0]
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
# ----------------------------------------------------------------------------- the VALUES
|
| 193 |
+
def cells(table_key, pids, st=None):
|
| 194 |
+
"""`{"<pid>": {field_key: value}}` for the rows named by `pids` β and ONLY those.
|
| 195 |
+
|
| 196 |
+
β `pids` IS REQUIRED. See the module header: this is the row wall expressed as a signature,
|
| 197 |
+
so a caller cannot get "every shared cell in the tenant" by forgetting an argument. Pass the
|
| 198 |
+
pool you have already scoped for this session.
|
| 199 |
+
|
| 200 |
+
An empty `pids` legitimately returns `{}` β a reader with no rows sees no cells, which is the
|
| 201 |
+
correct answer rather than a special case.
|
| 202 |
+
"""
|
| 203 |
+
if pids is None:
|
| 204 |
+
raise TypeError('shared_overlay.cells: pids is required β pass the row set you have '
|
| 205 |
+
'already scoped for this reader (there is deliberately no "all" call)')
|
| 206 |
+
wanted = {_pid(p) for p in pids}
|
| 207 |
+
if not wanted:
|
| 208 |
+
return {}
|
| 209 |
+
stored = _read(table_key, st)['cells']
|
| 210 |
+
return {pid: dict(row) for pid, row in stored.items()
|
| 211 |
+
if pid in wanted and isinstance(row, dict) and row}
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def snapshot(table_key, pids, st=None):
|
| 215 |
+
"""`(fields, cells)` for `pids` β the shared stratum's SCHEMA and VALUES, from ONE read.
|
| 216 |
+
|
| 217 |
+
ββ THIS EXISTS BECAUSE TWO READS OF THIS BUCKET CAN DISAGREE, AND THE DISAGREEMENT OPENS A
|
| 218 |
+
WALL. `perm_scope._enrich_for_wall` needs both halves: the schema decides WHICH user columns
|
| 219 |
+
the row wall may answer, and the cells are the values it answers with. Taking them as two
|
| 220 |
+
`st.get()` calls leaves a gap in which a concurrent write can land, and a wave-40 adversarial
|
| 221 |
+
probe drove exactly that: with the second read returning an emptied `cells`, a blank is merged
|
| 222 |
+
for a key the store cannot really serve and `custom_x is not West` flips from denying every row
|
| 223 |
+
to ADMITTING a row whose true value IS West. `is empty` does the same. The set and the values
|
| 224 |
+
must come from one snapshot or they are not talking about the same tenant state.
|
| 225 |
+
|
| 226 |
+
β `pids` IS REQUIRED, exactly as in `cells()` β the row wall expressed as a signature, so no
|
| 227 |
+
caller can get "every shared cell in the tenant" by forgetting an argument.
|
| 228 |
+
"""
|
| 229 |
+
if pids is None:
|
| 230 |
+
raise TypeError('shared_overlay.snapshot: pids is required β pass the row set you have '
|
| 231 |
+
'already scoped for this reader (there is deliberately no "all" call)')
|
| 232 |
+
stored = _read(table_key, st)
|
| 233 |
+
wanted = {_pid(p) for p in pids}
|
| 234 |
+
served = {pid: dict(row) for pid, row in stored['cells'].items()
|
| 235 |
+
if pid in wanted and isinstance(row, dict) and row} if wanted else {}
|
| 236 |
+
return dict(stored['fields']), served
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def put_cell(table_key, pid, field_key, value, st=None):
|
| 240 |
+
"""Write ONE shared cell. Returns the value as stored (which may be truncated)."""
|
| 241 |
+
return put_cells(table_key, pid, {field_key: value}, st=st).get(str(field_key or ''))
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def put_cells(table_key, pid, values, st=None):
|
| 245 |
+
"""Write several shared cells on one row, in one store update. Returns what was stored."""
|
| 246 |
+
row_id = _pid(pid)
|
| 247 |
+
clean = {str(k): _value(v) for k, v in dict(values or {}).items() if str(k)}
|
| 248 |
+
if not clean:
|
| 249 |
+
return {}
|
| 250 |
+
|
| 251 |
+
def _patch(data):
|
| 252 |
+
data['cells'].setdefault(row_id, {}).update(clean)
|
| 253 |
+
|
| 254 |
+
_write(table_key, _patch, st)
|
| 255 |
+
return clean
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def put_rows(table_key, rows, st=None):
|
| 259 |
+
"""Write shared cells on MANY rows in ONE store update. Returns `{row_id: {key: value}}`.
|
| 260 |
+
|
| 261 |
+
ββ THE WHOLE POINT IS THE *ONE*, AND IT IS NOT AN OPTIMISATION. `put_cells` is one row per
|
| 262 |
+
store write, so a 1,397-row catalog import is 1,397 download-modify-upload cycles against one
|
| 263 |
+
JSON document. This repo has a MEASURED scar for that shape: **18 writes against one document
|
| 264 |
+
under the store's coalescing single-flight landed ZERO while answering 200 eighteen times.**
|
| 265 |
+
Batching is what makes a bulk import land at all, not what makes it fast.
|
| 266 |
+
|
| 267 |
+
β It deliberately takes the WHOLE SET rather than accepting a stream: a caller that loops over
|
| 268 |
+
this function has simply rebuilt `put_cells` with extra steps, and the failure it reintroduces
|
| 269 |
+
is silent. If the set does not fit in memory it does not fit in this store either β that is a
|
| 270 |
+
signal to change substrate, not to chunk.
|
| 271 |
+
|
| 272 |
+
β NO CAP HERE, ON PURPOSE. The bound belongs at the DOOR, where a caller identity and a
|
| 273 |
+
reportable refusal exist (`routes_grid.bulk_cells`). A silent ceiling in a store primitive is
|
| 274 |
+
exactly the shape the standing no-cap rule forbids.
|
| 275 |
+
"""
|
| 276 |
+
clean = {}
|
| 277 |
+
for pid, values in dict(rows or {}).items():
|
| 278 |
+
row_id = _pid(pid)
|
| 279 |
+
cells_for_row = {str(k): _value(v) for k, v in dict(values or {}).items() if str(k)}
|
| 280 |
+
if cells_for_row:
|
| 281 |
+
clean.setdefault(row_id, {}).update(cells_for_row)
|
| 282 |
+
if not clean:
|
| 283 |
+
return {}
|
| 284 |
+
|
| 285 |
+
def _patch(data):
|
| 286 |
+
for row_id, values in clean.items():
|
| 287 |
+
data['cells'].setdefault(row_id, {}).update(values)
|
| 288 |
+
|
| 289 |
+
# `flush='sync'` because a bulk import must be durable when the call returns: the caller is a
|
| 290 |
+
# script that will report "1,397 rows written" and exit, and an async flush would make that
|
| 291 |
+
# sentence a prediction rather than a fact.
|
| 292 |
+
_write(table_key, _patch, st, flush='sync')
|
| 293 |
+
return clean
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
def clear_row(table_key, pid, st=None):
|
| 297 |
+
"""Forget every shared cell on one row. True when there was something to forget."""
|
| 298 |
+
row_id = _pid(pid)
|
| 299 |
+
hit = [False]
|
| 300 |
+
|
| 301 |
+
def _clear(data):
|
| 302 |
+
hit[0] = data['cells'].pop(row_id, None) is not None
|
| 303 |
+
|
| 304 |
+
_write(table_key, _clear, st)
|
| 305 |
+
return hit[0]
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
def prune(table_key, live_pids, st=None):
|
| 309 |
+
"""Drop shared cells for rows that no longer exist. Returns how many rows were dropped.
|
| 310 |
+
|
| 311 |
+
β NOT a scoped read wearing another name, and the difference is the whole reason `cells()`
|
| 312 |
+
refuses to serve everything: `live_pids` here means *every row this TABLE has*, which is a
|
| 313 |
+
fact about the data, whereas `pids` in `cells()` means *every row this READER may see*, which
|
| 314 |
+
is a fact about the session. Calling this with one session's pool would delete the shared
|
| 315 |
+
values of every row that session cannot see. The name says which is which; so does this note.
|
| 316 |
+
"""
|
| 317 |
+
keep = {_pid(p) for p in live_pids}
|
| 318 |
+
dropped = [0]
|
| 319 |
+
|
| 320 |
+
def _prune(data):
|
| 321 |
+
gone = [pid for pid in data['cells'] if pid not in keep]
|
| 322 |
+
for pid in gone:
|
| 323 |
+
data['cells'].pop(pid, None)
|
| 324 |
+
dropped[0] = len(gone)
|
| 325 |
+
|
| 326 |
+
_write(table_key, _prune, st, flush='sync')
|
| 327 |
+
return dropped[0]
|
web/src/settings/permsModel.ts
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|