forked from odysseus-dev/odysseus
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathauth_helpers.py
More file actions
148 lines (124 loc) · 6.26 KB
/
Copy pathauth_helpers.py
File metadata and controls
148 lines (124 loc) · 6.26 KB
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
"""Shared auth helpers used by all route files."""
import os
from typing import Optional
from fastapi import Request, HTTPException
def get_current_user(request: Request) -> Optional[str]:
"""Get current username from request state (set by auth middleware)."""
return getattr(request.state, 'current_user', None)
def effective_user(request: Request) -> Optional[str]:
"""The real human behind the request, for ownership/attribution.
Cookie sessions resolve to the logged-in username. Bearer ``ody_`` callers
come through as the sandboxed pseudo-user "api" so they can't wander into
cookie/user routes by default, but their token was minted by, and belongs
to, a real owner stamped on ``request.state.api_token_owner``. Routes that
should attribute a token's actions to that owner (sessions, chat history)
call this instead of :func:`get_current_user`, so a paired client sees and
creates the SAME data as the owner's desktop UI rather than a separate
"api"-owned silo.
For cookie sessions this is identical to :func:`get_current_user`, so
swapping a route over is a no-op for browser users. A bearer token with no
owner falls back to :func:`get_current_user` (the "api" pseudo-user), so it
never escalates.
"""
if getattr(request.state, "api_token", False):
owner = getattr(request.state, "api_token_owner", None)
if owner:
return owner
return get_current_user(request)
def _is_api_token_request(request: Request) -> bool:
"""Return True when middleware authenticated a bearer API token."""
return bool(getattr(request.state, "api_token", False))
def require_authenticated_request(request: Request) -> str:
"""Allow either a browser session or a valid bearer API token.
This is intentionally narrower than :func:`require_user`: use it only for
routes that need authentication but do not read or mutate owner-scoped
user data. Owner-scoped routes should use ``require_user`` for browser
sessions or their own API-token scope/owner gate.
"""
if _is_api_token_request(request):
return effective_user(request) or ""
return require_user(request)
def _auth_disabled() -> bool:
"""True when the operator has explicitly turned off auth via .env.
Mirrors the AUTH_ENABLED parse in app.py / core/middleware.py so the
three call sites agree on what "off" means."""
return os.getenv("AUTH_ENABLED", "true").lower() == "false"
def require_user(request: Request) -> str:
"""FastAPI dependency: reject unauthenticated callers when the upstream
auth middleware was bypassed unexpectedly (e.g. SSRF from a sibling
service). Returns the resolved username, or "" in single-user / anonymous
modes where no username is available.
The three "" cases are:
1. AUTH_ENABLED=false — the operator explicitly turned auth off.
The full /login flow is skipped (issue #622), so route-level
require_user must let the request through too instead of 401-ing
and forcing the browser to /login.
2. Unconfigured first-run + loopback caller — pre-setup access from
localhost so the operator can hit the SPA before creating the
first admin.
3. LOCALHOST_BYPASS=true + loopback caller — documented dev bypass.
Use this on routes that touch user data so middleware misconfig can't
open them up.
"""
if _is_api_token_request(request):
raise HTTPException(403, "API tokens must use a scope-aware API route")
u = get_current_user(request)
if u:
return u
# Operator-disabled auth: honor it at the route layer too. Without this,
# routes that depend on require_user 401, the front-end fetch wrapper
# redirects to /login, and the user sees a login page despite
# AUTH_ENABLED=false (issue #622). Docker / reverse-proxy deployments
# hit this because requests arrive from a non-loopback client.host, so
# the loopback fall-through below never fires.
if _auth_disabled():
return ""
auth_mgr = getattr(request.app.state, "auth_manager", None)
client = getattr(request, "client", None)
host = (client.host if client else "") or ""
is_loopback = host in ("127.0.0.1", "::1", "localhost")
# LOCALHOST_BYPASS=true is the dev-only "I'm on loopback, skip auth"
# switch. Mirror the middleware so routes don't 401 the same caller
# the middleware just let through.
if is_loopback and os.getenv("LOCALHOST_BYPASS", "false").lower() == "true":
return ""
if auth_mgr is not None and getattr(auth_mgr, "is_configured", False):
raise HTTPException(401, "Not authenticated")
# Unconfigured / first-run mode: only allow loopback callers.
if is_loopback:
return ""
raise HTTPException(401, "Not authenticated")
def require_privilege(request: Request, key: str) -> str:
"""Reject callers whose `auth.json` privilege flag for `key` is False.
Returns the username so the route handler can keep using it.
Admins always have every privilege via `auth_manager.get_privileges`
(which returns ADMIN_PRIVILEGES wholesale), so this is a no-op for
them. In unauthenticated single-user mode (`require_user` returns ""),
privileges aren't enforced.
"""
user = require_user(request)
if not user:
return user
auth_mgr = getattr(request.app.state, "auth_manager", None)
if auth_mgr is None:
return user
try:
privs = auth_mgr.get_privileges(user) or {}
except Exception:
return user
if not isinstance(privs, dict):
privs = {}
# True = permitted; missing key defaults to permitted (unknown privileges
# fail open — the UI gates display-side).
if not privs.get(key, True):
raise HTTPException(403, f"Your account is not allowed to {key.replace('_', ' ')}.")
return user
def owner_filter(query, model_cls, user: str, *, include_shared: bool = True):
"""Filter `query` so only rows owned by `user` (and optionally null-owner
'shared' rows) come through. No-op when `user` is empty (single-user
mode). Returns the modified query."""
if not user:
return query
if include_shared:
return query.filter((model_cls.owner == user) | (model_cls.owner == None)) # noqa: E711
return query.filter(model_cls.owner == user)