File size: 12,559 Bytes
092334a ea7b176 092334a c14ceee 092334a 4748aae 092334a 4748aae 092334a 4748aae 092334a 4748aae 092334a 4748aae 092334a ea7b176 637946b ea7b176 092334a 637946b ea7b176 092334a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 | """deps.py β the request-scoped session + tenant resolution every v1 route depends on.
ONE place answers "who is asking, for which tenant, and what may they see?", because a
permission check that exists in four routes has four chances to be forgotten. The routes take
`session: Session = Depends(require_session)` and receive an object that has ALREADY failed
closed if anything was wrong.
FAIL-CLOSED, spelled out (X2): no cookie β **401**. Cookie present but unverifiable, expired,
epoch-revoked, or naming an unknown tenant β **401**. Authenticated but not granted the surface β
**403**. Never an empty 200: an empty list is a legitimate answer meaning "no rows", and using it
to mean "you are not allowed" is how a permission bug becomes invisible.
"""
import os
import sys
import time
from collections import OrderedDict
from dataclasses import dataclass
from pathlib import Path
from fastapi import Depends, HTTPException, Request, Response
import aios_session
_RI = Path(os.environ.get("RI_DIR") or Path(__file__).resolve().parents[2] / "platform")
if str(_RI) not in sys.path:
sys.path.insert(0, str(_RI))
import core.perms as perms # noqa: E402
import core.users as users # noqa: E402
from harness import runtime # noqa: E402
def err(status, code, message):
"""The one error shape (X2): non-2xx JSON `{"error": {"code", "message"}}`."""
return HTTPException(status_code=status, detail={"error": {"code": code, "message": message}})
@dataclass
class Session:
"""A verified request identity. `user` is the PUBLIC record (never a hash or a salt)."""
tenant: str
user: dict
claims: dict
runtime: object
@property
def uname(self):
return self.user.get("username", "")
@property
def admin(self):
return perms.is_admin(self.user)
def require(self, module_key):
"""403 unless this session may open `module_key`. Returns nothing β it is a gate.
TWO walls, both fail-closed (wave 18, C1-TENANT): the ACCOUNT grant (`perms.may_open`)
and the TENANT catalogue β a registry module a tenant has not enabled is 403 even for
that tenant's admin, or a freshly provisioned Nurilab admin (role=admin β may_open
everything) would open Royal's Odoo-backed surfaces through any module route. One
chokepoint: `module_gate` and every inline `session.require` pass through here."""
if not perms.may_open(self.user, module_key):
raise err(403, "forbidden", f"your account does not have access to {module_key}")
tcfg = getattr(self.runtime.tenant, "config", None) or {}
tmods = tcfg.get("modules", "all")
if tmods != "all" and str(module_key) not in {str(k) for k in (tmods or [])}:
raise err(403, "forbidden",
f"this workspace does not include {module_key}")
def _user_for(claims):
"""The account named by a verified cookie, or None β with the epoch check that makes a
stateless cookie revocable.
β THE RULE THIS FUNCTION MUST OBEY: **it has to accept exactly the identities `users.verify`
issues.** Any divergence produces the worst failure shape there is β a login that returns 200
with a cookie and then 401s every request after it. This function got that wrong once (the
emergency-master branch below ran only when NO record existed, while `verify` grants the master
identity even when one does), so it is now written as a deliberate mirror of `verify`'s
structure: try the record, else the master.
TWO CASES, in `verify`'s own order:
* THE RECORD, if it is readable and satisfies everything a session adds on top of a login:
the account is active, and its CURRENT epoch equals the cookie's β otherwise the session
is revoked (a password change or a deactivation bumped it).
* THE EMERGENCY MASTER β username 'admin' with APP_PASSWORD configured β exactly as
`verify`'s last branch grants it, whether or not a record exists. This is what stops a
store outage, a self-deactivation or a bumped epoch locking the owner out of the product.
β AND IT MEANS AN `admin` SESSION IS NOT EPOCH-REVOCABLE while APP_PASSWORD is set. That
is not a weakening: whoever holds APP_PASSWORD can simply log in again, so bumping admin's
epoch never revoked them in the first place. Rotating APP_PASSWORD is how you revoke it.
Every OTHER account is fully epoch-revocable, which is asserted in `verify_api.py`.
* anything else β None. An unknown username is NOT admitted just because its signature was
valid: a signature proves the cookie is ours, not that the account still exists.
"""
uname = (claims.get("u") or "").strip().lower()
if not uname:
return None
claim_tenant = str(claims.get("t") or "").strip().lower()
try:
reg = users.registry() or {}
except Exception:
reg = {}
rec = reg.get(uname)
if (rec and rec.get("active", True)
and int(rec.get("epoch") or 0) == int(claims.get("e") or 0)):
pub = users._public(uname, rec)
# β Wave 18 (C1-TENANT, R1): the cookie's tenant must be THE ACCOUNT'S tenant. Before
# this line, any valid credential could mint a session for any registered slug
# (login validated the slug's existence, never the membership) β with
# AIOS_ENABLE_QA_TENANT=1 that was a working cross-tenant session. A signature proves
# the cookie is ours; THIS proves the account belongs where the cookie says it does.
if claim_tenant != pub.get("tenant", "royal-imports"):
return None
return pub
if (uname == "admin" and os.environ.get("APP_PASSWORD", "")
and claim_tenant == "royal-imports"):
# The emergency master is TENANT #0's break-glass, not the platform's: a synthetic
# all-access identity minted into another tenant's session would be the exact
# cross-tenant widening the rule above closes.
return {"username": "admin", "name": "Administrator", "role": "admin",
"bus": "all", "modules": "all", "tenant": "royal-imports",
"epoch": int(claims.get("e") or 0)}
return None
# --- wave 19 (R4): the LAST-ACTIVE stamp, throttled in this process ------------------------------
# "Is anyone actually using this account?" is one of the two questions the Loopable admin plane
# answers, and the only place that can honestly answer it is the session resolver β every
# authenticated request passes through here.
#
# β THEREFORE IT MUST COST ALMOST NOTHING. A store write per request would put the HF store's
# 256-commits/hour budget on the critical path of the entire API, and a store READ per request to
# decide whether to write would be little better. So the staleness decision is made from a
# PROCESS-LOCAL map: no I/O at all on the 99.97% of requests inside the window, and the write
# itself (once an hour per account) happens on `core.users`' own background thread, so no request
# ever waits for a hub round-trip. `core.users` explains why that write is SYNC and not the
# coalesced async path β `users.json` is shared with the Streamlit host and an async flush rebases
# on this process's cache, which would let a stamp revert somebody else's deactivation.
#
# HONEST LIMITS, stated rather than discovered later: the map is per-process, so N workers can each
# write once per window (still bounded); a restart re-arms every user's first request; and the
# resolution of `last_active` is therefore "within the last hour", which is exactly what the plane
# renders it as. Bounded like `routes_auth._FAILS` so it cannot grow into a leak on a deployment
# with many accounts.
_ACTIVE_SEEN: "OrderedDict[str, float]" = OrderedDict()
_ACTIVE_EVERY = float(os.environ.get("AIOS_ACTIVE_STAMP_SECONDS") or 3600)
_ACTIVE_MAX_TRACKED = 4096
#: β MUST MATCH `web/src/apiContract.ts::TENANT_HEADER` EXACTLY β the client compares this
#: header against the tenant its frame booted with, and a rename on one side alone turns the
#: guard OFF silently (an absent header reads as "no change", by design, so nothing would go
#: red). `verify_wiring.py` pins both spellings for that reason.
_TENANT_HEADER = "X-AIOS-Tenant"
def note_active(uname, when=None):
"""Record that `uname` was just seen β WITHOUT writing. The login path calls this because it
has already stamped `last_active` itself; without it the very next request would find no entry
and stamp again immediately, making the "at most once an hour" rule false by one write per
sign-in."""
key = (uname or "").strip().lower()
if not key:
return
_ACTIVE_SEEN[key] = when if when is not None else time.time()
_ACTIVE_SEEN.move_to_end(key)
while len(_ACTIVE_SEEN) > _ACTIVE_MAX_TRACKED:
_ACTIVE_SEEN.popitem(last=False)
def _touch_active(uname):
"""Stamp `last_active` at most once per window per account, per process. Never raises."""
try:
key = (uname or "").strip().lower()
if not key:
return
now = time.time()
last = _ACTIVE_SEEN.get(key)
if last is not None and (now - last) < _ACTIVE_EVERY:
return
note_active(key, now)
users.touch_active(key)
except Exception: # noqa: BLE001 β telemetry may never break a request
pass
def require_session(request: Request, response: Response) -> Session:
"""Verify the cookie, resolve the tenant, and REISSUE the cookie (the 8h idle window is
refreshed on use; the 30-day absolute expiry is carried over, never extended)."""
raw = request.cookies.get(aios_session.COOKIE_NAME)
if not raw:
raise err(401, "no_session", "sign in to continue")
claims = aios_session.read(raw)
if not claims:
raise err(401, "invalid_session", "your session has expired β sign in again")
user = _user_for(claims)
if not user:
raise err(401, "invalid_session", "your session has expired β sign in again")
try:
rt = runtime.get_runtime(claims["t"])
except KeyError:
# An unknown tenant slug is a 401, not a 404: the cookie is not a valid identity for this
# deployment, and confirming which slugs exist would answer a question we were not asked.
raise err(401, "invalid_session", "your session has expired β sign in again")
fresh, _ = aios_session.mint(claims["t"], user["username"], int(user.get("epoch") or 0),
absolute_expiry=claims["x"])
aios_session.set_cookie(response, request, fresh)
# β STAMP THE TENANT THIS ANSWER WAS SERVED FOR (owner report 2026-08-09: "I can see
# Nurilab's automations when logged into Royal Imports").
#
# β THE SERVER WAS NEVER WRONG β MEASURED, link by link: per-tenant dataset repos,
# `get_runtime(claims["t"])` off the SIGNED cookie, and the `claim_tenant != pub["tenant"]`
# refusal above. The browser is what blends them: `aios_session` is ONE cookie at `path="/"`
# per ORIGIN, so signing into tenant B in a second tab silently repoints the FIRST tab. That
# tab goes on painting tenant A's chrome while every new request is answered for tenant B β
# and a WRITE from it lands in tenant B's store, which is the same event reported separately
# as "I updated the data and it doesn't register".
#
# It is stamped HERE rather than in a middleware because this is the one function every
# session-authed route passes through, so no route can be added that forgets it β and it is
# the only place that has already RESOLVED the tenant rather than guessed it.
# β The value is `claims["t"]`, the tenant the cookie was VERIFIED for β never
# `session.tenant` read back from something the request could influence.
response.headers[_TENANT_HEADER] = str(claims["t"])
# R4: last AFTER the session is fully resolved β a stamp is only true of a request that was
# actually admitted, and putting it here means no failed-auth path can ever write one.
_touch_active(user.get("username"))
return Session(tenant=claims["t"], user=user, claims=claims, runtime=rt)
def module_gate(module_key):
"""A dependency that 401s without a session and 403s without the grant for `module_key`."""
def _dep(session: Session = Depends(require_session)) -> Session:
session.require(module_key)
return session
return _dep
|