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