Deploy AIOS web (React glide grid + FastAPI slice)
Browse files- RELEASES.json +7 -1
- VERSION +1 -1
- api/deps.py +5 -4
- api/routes_auth.py +4 -3
- api/routes_customers.py +35 -2
- api/routes_shares.py +7 -0
- platform/core/users.py +35 -11
RELEASES.json
CHANGED
|
@@ -1,6 +1,12 @@
|
|
| 1 |
{
|
| 2 |
-
"current": "
|
| 3 |
"releases": [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
{
|
| 5 |
"version": "v34",
|
| 6 |
"sha": "cb4caa1",
|
|
|
|
| 1 |
{
|
| 2 |
+
"current": "v35 (0f64212)",
|
| 3 |
"releases": [
|
| 4 |
+
{
|
| 5 |
+
"version": "v35",
|
| 6 |
+
"sha": "0f64212",
|
| 7 |
+
"date": "2026-08-21",
|
| 8 |
+
"subject": "W39 visual QA fixture"
|
| 9 |
+
},
|
| 10 |
{
|
| 11 |
"version": "v34",
|
| 12 |
"sha": "cb4caa1",
|
VERSION
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
|
|
|
|
| 1 |
+
v35 (0f64212)
|
api/deps.py
CHANGED
|
@@ -515,7 +515,7 @@ def _user_for(claims):
|
|
| 515 |
if not uname:
|
| 516 |
return None
|
| 517 |
claim_tenant = str(claims.get("t") or "").strip().lower()
|
| 518 |
-
# W39-T28 / A62. This MUST precede the registry read:
|
| 519 |
# zero-touch qa-b identity, not an account in tenant #0's `users` document. The helper
|
| 520 |
# rechecks both non-production deployment flags, so removing either invalidates its cookie.
|
| 521 |
qa = users.qa_identity(uname)
|
|
@@ -656,9 +656,10 @@ def require_session(request: Request, response: Response) -> Session:
|
|
| 656 |
response.headers[_TENANT_HEADER] = str(claims["t"])
|
| 657 |
# R4: last AFTER the session is fully resolved β a stamp is only true of a request that was
|
| 658 |
# actually admitted, and putting it here means no failed-auth path can ever write one.
|
| 659 |
-
#
|
| 660 |
-
# touch one through the default tenant-#0 users store
|
| 661 |
-
|
|
|
|
| 662 |
_touch_active(user.get("username"))
|
| 663 |
return Session(tenant=claims["t"], user=user, claims=claims, runtime=rt)
|
| 664 |
|
|
|
|
| 515 |
if not uname:
|
| 516 |
return None
|
| 517 |
claim_tenant = str(claims.get("t") or "").strip().lower()
|
| 518 |
+
# W39-T28 / A62. This MUST precede the registry read: a synthetic QA principal is a
|
| 519 |
# zero-touch qa-b identity, not an account in tenant #0's `users` document. The helper
|
| 520 |
# rechecks both non-production deployment flags, so removing either invalidates its cookie.
|
| 521 |
qa = users.qa_identity(uname)
|
|
|
|
| 656 |
response.headers[_TENANT_HEADER] = str(claims["t"])
|
| 657 |
# R4: last AFTER the session is fully resolved β a stamp is only true of a request that was
|
| 658 |
# actually admitted, and putting it here means no failed-auth path can ever write one.
|
| 659 |
+
# Synthetic QA identities have no global account record. Never let normal activity telemetry
|
| 660 |
+
# create or touch one through the default tenant-#0 users store (the visual fixture has two
|
| 661 |
+
# Members, so this must not be specific to qa-runner).
|
| 662 |
+
if not user.get("qa_fixture"):
|
| 663 |
_touch_active(user.get("username"))
|
| 664 |
return Session(tenant=claims["t"], user=user, claims=claims, runtime=rt)
|
| 665 |
|
api/routes_auth.py
CHANGED
|
@@ -187,15 +187,16 @@ def login(request: Request, response: Response, body: dict = Body(default=None))
|
|
| 187 |
# stamping what was typed would write onto a key that does not exist. Fail-silent inside
|
| 188 |
# `touch_login` and a no-op for the emergency-master identity (which has no record to stamp),
|
| 189 |
# so the one thing this cannot do is turn a good credential into a failed sign-in.
|
| 190 |
-
#
|
| 191 |
# registry stamp here would default to tenant #0 and make a qa-b proof touch the wrong schema.
|
| 192 |
-
|
|
|
|
| 193 |
users.touch_login(user["username"])
|
| 194 |
# `touch_login` also sets `last_active`, so tell the session resolver it has been seen β
|
| 195 |
# otherwise the very next request stamps it again and the hourly throttle is off by one write
|
| 196 |
# per sign-in.
|
| 197 |
import deps as _deps
|
| 198 |
-
if not user.get("
|
| 199 |
_deps.note_active(user["username"])
|
| 200 |
return {"user": _public_user(user)}
|
| 201 |
|
|
|
|
| 187 |
# stamping what was typed would write onto a key that does not exist. Fail-silent inside
|
| 188 |
# `touch_login` and a no-op for the emergency-master identity (which has no record to stamp),
|
| 189 |
# so the one thing this cannot do is turn a good credential into a failed sign-in.
|
| 190 |
+
# Synthetic QA identities have no normal account record to stamp. More importantly, a
|
| 191 |
# registry stamp here would default to tenant #0 and make a qa-b proof touch the wrong schema.
|
| 192 |
+
# This is ``qa_fixture`` rather than ``qa_runner`` because visual sharing needs two Members.
|
| 193 |
+
if not user.get("qa_fixture"):
|
| 194 |
users.touch_login(user["username"])
|
| 195 |
# `touch_login` also sets `last_active`, so tell the session resolver it has been seen β
|
| 196 |
# otherwise the very next request stamps it again and the hourly throttle is off by one write
|
| 197 |
# per sign-in.
|
| 198 |
import deps as _deps
|
| 199 |
+
if not user.get("qa_fixture"):
|
| 200 |
_deps.note_active(user["username"])
|
| 201 |
return {"user": _public_user(user)}
|
| 202 |
|
api/routes_customers.py
CHANGED
|
@@ -38,6 +38,31 @@ MODULE = "customer_data"
|
|
| 38 |
_CACHE_TTL = 900 # the pool build is slow (Odoo + reconciliation); 15 min, as before
|
| 39 |
|
| 40 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
def _pool_rows(session: Session):
|
| 42 |
"""The reconciled Odoo pool for this session's SCOPE β the slow part, and the only part that
|
| 43 |
may be shared between users.
|
|
@@ -59,6 +84,9 @@ def _pool_rows(session: Session):
|
|
| 59 |
`(team_id, agent)` pair, so no two of them ever collided on the key β the test set could not
|
| 60 |
express the bug. `verify_api.py` now carries a same-scope second user for exactly this.
|
| 61 |
"""
|
|
|
|
|
|
|
|
|
|
| 62 |
rt = session.runtime
|
| 63 |
team_id, agent = _team_agent(session)
|
| 64 |
return _pool_for(rt, team_id, agent)
|
|
@@ -245,7 +273,8 @@ def grid_assembly(session: Session, scope: str = "customer", storage_key: str =
|
|
| 245 |
|
| 246 |
rt = session.runtime
|
| 247 |
team_id, agent = _team_agent(session)
|
| 248 |
-
|
|
|
|
| 249 |
# β THE ROW WALL, APPLIED BEFORE `pids` IS TAKEN. Everything downstream is bounded by that
|
| 250 |
# frozenset β `allowed_pids` for the workspace, cohort membership, measure resolution β so
|
| 251 |
# scoping here means a row this account may not see never enters ANY of them, rather than
|
|
@@ -333,7 +362,11 @@ def grid_assembly(session: Session, scope: str = "customer", storage_key: str =
|
|
| 333 |
# picker and refuse new creates while an EXISTING Metric column kept computing and an
|
| 334 |
# EXISTING measure condition kept resolving β a revoked capability still answering, on the
|
| 335 |
# grain with the most of them. Three calls, one predicate.
|
| 336 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 337 |
measures = measure_resolve.offer(team_id, on_error=_measure_err) if may_metrics else []
|
| 338 |
measure_sets = measure_resolve.condition_sets(
|
| 339 |
[v.get("config") or {} for v in (views or [])], None, team_id, pids, today, stamp,
|
|
|
|
| 38 |
_CACHE_TTL = 900 # the pool build is slow (Odoo + reconciliation); 15 min, as before
|
| 39 |
|
| 40 |
|
| 41 |
+
# W39 visual QA fixture. The staging-only QA tenant has an intentionally empty connector, so it
|
| 42 |
+
# can prove tenant isolation; a browser cannot, however, exercise Map routing or a third-row Go
|
| 43 |
+
# link with zero rows. These static, non-customer coordinates are the narrowest harmless fixture:
|
| 44 |
+
# three local map points, no Odoo source access, and no path for a normal tenant to receive them.
|
| 45 |
+
_QA_CUSTOMER_ROWS = (
|
| 46 |
+
{"pid": 9_900_001, "name": "QA Route North", "dba": "QA", "agent": "QA",
|
| 47 |
+
"_created": "2026-08-21", "lat": 41.8884, "lon": -87.6354,
|
| 48 |
+
"revenue_ytd": 0.0, "orders_24m": 0, "balance": 0.0, "overdue": 0.0, "ar_open": 0.0},
|
| 49 |
+
{"pid": 9_900_002, "name": "QA Route Centre", "dba": "QA", "agent": "QA",
|
| 50 |
+
"_created": "2026-08-21", "lat": 41.8781, "lon": -87.6298,
|
| 51 |
+
"revenue_ytd": 0.0, "orders_24m": 0, "balance": 0.0, "overdue": 0.0, "ar_open": 0.0},
|
| 52 |
+
{"pid": 9_900_003, "name": "QA Route South", "dba": "QA", "agent": "QA",
|
| 53 |
+
"_created": "2026-08-21", "lat": 41.8677, "lon": -87.6241,
|
| 54 |
+
"revenue_ytd": 0.0, "orders_24m": 0, "balance": 0.0, "overdue": 0.0, "ar_open": 0.0},
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _qa_customer_rows(session: Session):
|
| 59 |
+
"""A detached three-row fixture for the admitted non-production QA identities only."""
|
| 60 |
+
if (session.tenant == "qa-b" and isinstance(session.user, dict)
|
| 61 |
+
and session.user.get("qa_fixture") is True):
|
| 62 |
+
return [dict(row) for row in _QA_CUSTOMER_ROWS]
|
| 63 |
+
return None
|
| 64 |
+
|
| 65 |
+
|
| 66 |
def _pool_rows(session: Session):
|
| 67 |
"""The reconciled Odoo pool for this session's SCOPE β the slow part, and the only part that
|
| 68 |
may be shared between users.
|
|
|
|
| 84 |
`(team_id, agent)` pair, so no two of them ever collided on the key β the test set could not
|
| 85 |
express the bug. `verify_api.py` now carries a same-scope second user for exactly this.
|
| 86 |
"""
|
| 87 |
+
qa_rows = _qa_customer_rows(session)
|
| 88 |
+
if qa_rows is not None:
|
| 89 |
+
return qa_rows
|
| 90 |
rt = session.runtime
|
| 91 |
team_id, agent = _team_agent(session)
|
| 92 |
return _pool_for(rt, team_id, agent)
|
|
|
|
| 273 |
|
| 274 |
rt = session.runtime
|
| 275 |
team_id, agent = _team_agent(session)
|
| 276 |
+
qa_rows = _qa_customer_rows(session)
|
| 277 |
+
rows_src = qa_rows if qa_rows is not None else _pool_for(rt, team_id, agent)
|
| 278 |
# β THE ROW WALL, APPLIED BEFORE `pids` IS TAKEN. Everything downstream is bounded by that
|
| 279 |
# frozenset β `allowed_pids` for the workspace, cohort membership, measure resolution β so
|
| 280 |
# scoping here means a row this account may not see never enters ANY of them, rather than
|
|
|
|
| 362 |
# picker and refuse new creates while an EXISTING Metric column kept computing and an
|
| 363 |
# EXISTING measure condition kept resolving β a revoked capability still answering, on the
|
| 364 |
# grain with the most of them. Three calls, one predicate.
|
| 365 |
+
# A QA fixture must not turn a browser proof into an Odoo measurement request. The synthetic
|
| 366 |
+
# rows intentionally exercise grid/map/navigation only; metrics have no fixture truth and are
|
| 367 |
+
# therefore unavailable rather than silently reaching a real source.
|
| 368 |
+
may_metrics = (not bool(session.user.get("qa_fixture"))
|
| 369 |
+
and perm_scope.may_metrics(session.user, MODULE))
|
| 370 |
measures = measure_resolve.offer(team_id, on_error=_measure_err) if may_metrics else []
|
| 371 |
measure_sets = measure_resolve.condition_sets(
|
| 372 |
[v.get("config") or {} for v in (views or [])], None, team_id, pids, today, stamp,
|
api/routes_shares.py
CHANGED
|
@@ -282,6 +282,13 @@ def get_share(kind: str, oid: str, session: Session = Depends(require_session)):
|
|
| 282 |
def _people(tenant):
|
| 283 |
"""[{username, name}] for this tenant β same population as `assignable_people`, with the
|
| 284 |
BINDING identity alongside the display one."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 285 |
try:
|
| 286 |
reg = users.registry() or {}
|
| 287 |
except Exception:
|
|
|
|
| 282 |
def _people(tenant):
|
| 283 |
"""[{username, name}] for this tenant β same population as `assignable_people`, with the
|
| 284 |
BINDING identity alongside the display one."""
|
| 285 |
+
# W39 visual QA: qa-b is an explicitly enabled, synthetic two-Member fixture. Its roster
|
| 286 |
+
# must resolve before the normal registry path: that registry is tenant #0 on Postgres, so
|
| 287 |
+
# even a read-only Share dialog would otherwise cross the isolation boundary the fixture is
|
| 288 |
+
# intended to prove. ``None`` preserves the ordinary registry route for every real tenant.
|
| 289 |
+
qa = users.qa_people(tenant)
|
| 290 |
+
if qa is not None:
|
| 291 |
+
return qa
|
| 292 |
try:
|
| 293 |
reg = users.registry() or {}
|
| 294 |
except Exception:
|
platform/core/users.py
CHANGED
|
@@ -27,12 +27,41 @@ PERMS_VERSION = 1
|
|
| 27 |
# identity would make a staging QA check depend on (and potentially mutate) a production-shaped
|
| 28 |
# account document. The identity exists only on an explicitly-granted non-production build.
|
| 29 |
_QA_TENANT = 'qa-b'
|
| 30 |
-
_QA_USERNAME = 'qa-runner'
|
| 31 |
_QA_TENANT_GRANT = 'AIOS_SANDBOX_TENANTS'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
|
| 34 |
def qa_identity(username, pw=None):
|
| 35 |
-
"""Return
|
| 36 |
|
| 37 |
Both deployment knobs are required: registration without a schema grant may not authenticate
|
| 38 |
a writer, and a typed grant without the registration flag may not mint an identity. The
|
|
@@ -43,13 +72,8 @@ def qa_identity(username, pw=None):
|
|
| 43 |
the same deployment guards still have to hold when it is read back.
|
| 44 |
"""
|
| 45 |
uname = (username or '').strip().lower()
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
if os.environ.get('AIOS_ENABLE_QA_TENANT') != '1':
|
| 49 |
-
return None
|
| 50 |
-
granted = {s.strip().lower() for s in
|
| 51 |
-
str(os.environ.get(_QA_TENANT_GRANT) or '').split(',') if s.strip()}
|
| 52 |
-
if _QA_TENANT not in granted:
|
| 53 |
return None
|
| 54 |
master = os.environ.get('APP_PASSWORD', '')
|
| 55 |
if not master:
|
|
@@ -61,9 +85,9 @@ def qa_identity(username, pw=None):
|
|
| 61 |
# exposing the secret; rotating APP_PASSWORD invalidates outstanding QA cookies just as an
|
| 62 |
# account-record epoch bump would, while still doing zero registry I/O.
|
| 63 |
qa_epoch = int.from_bytes(hashlib.sha256(master.encode('utf-8')).digest()[:4], 'big')
|
| 64 |
-
return {'username':
|
| 65 |
'bus': 'all', 'modules': ['customers'], 'tenant': _QA_TENANT,
|
| 66 |
-
'epoch': qa_epoch, 'qa_runner': True}
|
| 67 |
|
| 68 |
|
| 69 |
def _hash(pw, salt):
|
|
|
|
| 27 |
# identity would make a staging QA check depend on (and potentially mutate) a production-shaped
|
| 28 |
# account document. The identity exists only on an explicitly-granted non-production build.
|
| 29 |
_QA_TENANT = 'qa-b'
|
|
|
|
| 30 |
_QA_TENANT_GRANT = 'AIOS_SANDBOX_TENANTS'
|
| 31 |
+
_QA_IDENTITIES = {
|
| 32 |
+
# These principals are deliberately *not* records in ``users``. They exist only to make a
|
| 33 |
+
# Staging browser proof exercise the same Member-to-Member share picker that the product
|
| 34 |
+
# serves, without enumerating tenant #0's account document.
|
| 35 |
+
'qa-runner': {'name': 'QA Runner', 'qa_runner': True},
|
| 36 |
+
'qa-member': {'name': 'QA Member', 'qa_runner': False},
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _qa_enabled():
|
| 41 |
+
"""Whether the explicit non-production QA fixture is admitted, without registry I/O."""
|
| 42 |
+
if os.environ.get('AIOS_ENABLE_QA_TENANT') != '1':
|
| 43 |
+
return False
|
| 44 |
+
granted = {s.strip().lower() for s in
|
| 45 |
+
str(os.environ.get(_QA_TENANT_GRANT) or '').split(',') if s.strip()}
|
| 46 |
+
return _QA_TENANT in granted
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def qa_people(tenant):
|
| 50 |
+
"""The non-production-only picker roster, or ``None`` outside the admitted fixture.
|
| 51 |
+
|
| 52 |
+
``None`` deliberately means "use the ordinary tenant registry"; an empty list would make a
|
| 53 |
+
malformed/disabled configuration look like a real tenant with no accounts. This is kept next
|
| 54 |
+
to ``qa_identity`` so the roster and the principals cannot drift into two admission rules.
|
| 55 |
+
"""
|
| 56 |
+
if not _qa_enabled() or str(tenant or '').strip().lower() != _QA_TENANT:
|
| 57 |
+
return None
|
| 58 |
+
return sorted(({"username": uname, "name": spec["name"]}
|
| 59 |
+
for uname, spec in _QA_IDENTITIES.items()),
|
| 60 |
+
key=lambda person: person["name"].lower())
|
| 61 |
|
| 62 |
|
| 63 |
def qa_identity(username, pw=None):
|
| 64 |
+
"""Return an explicitly admitted Staging QA principal, or ``None``.
|
| 65 |
|
| 66 |
Both deployment knobs are required: registration without a schema grant may not authenticate
|
| 67 |
a writer, and a typed grant without the registration flag may not mint an identity. The
|
|
|
|
| 72 |
the same deployment guards still have to hold when it is read back.
|
| 73 |
"""
|
| 74 |
uname = (username or '').strip().lower()
|
| 75 |
+
spec = _QA_IDENTITIES.get(uname)
|
| 76 |
+
if spec is None or not _qa_enabled():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
return None
|
| 78 |
master = os.environ.get('APP_PASSWORD', '')
|
| 79 |
if not master:
|
|
|
|
| 85 |
# exposing the secret; rotating APP_PASSWORD invalidates outstanding QA cookies just as an
|
| 86 |
# account-record epoch bump would, while still doing zero registry I/O.
|
| 87 |
qa_epoch = int.from_bytes(hashlib.sha256(master.encode('utf-8')).digest()[:4], 'big')
|
| 88 |
+
return {'username': uname, 'name': spec['name'], 'role': 'user',
|
| 89 |
'bus': 'all', 'modules': ['customers'], 'tenant': _QA_TENANT,
|
| 90 |
+
'epoch': qa_epoch, 'qa_runner': bool(spec['qa_runner']), 'qa_fixture': True}
|
| 91 |
|
| 92 |
|
| 93 |
def _hash(pw, salt):
|