| """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 |
| import core.users as users |
| from harness import runtime |
|
|
|
|
| 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) |
| |
| |
| |
| |
| |
| 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"): |
| |
| |
| |
| return {"username": "admin", "name": "Administrator", "role": "admin", |
| "bus": "all", "modules": "all", "tenant": "royal-imports", |
| "epoch": int(claims.get("e") or 0)} |
| return None |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| _ACTIVE_SEEN: "OrderedDict[str, float]" = OrderedDict() |
| _ACTIVE_EVERY = float(os.environ.get("AIOS_ACTIVE_STAMP_SECONDS") or 3600) |
| _ACTIVE_MAX_TRACKED = 4096 |
|
|
| |
| |
| |
| |
| _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: |
| 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: |
| |
| |
| 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) |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| response.headers[_TENANT_HEADER] = str(claims["t"]) |
| |
| |
| _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 |
|
|