Skip to content

Commit daf84a2

Browse files
ryanmcmillanmarty-clawed-botclaude
authored
feat: trusted-proxy identity from Remote-User / X-Forwarded-User (#23)
When a request reaches the backend from an IP listed in API_TRUSTED_PROXY_IPS and carries Remote-User (or X-Forwarded-User) naming an active agent, the auth-gate accepts it without an X-Agent-Key. This is the contract the gateway (Caddy → Authelia) needs to hand off browser SSO identity to the API. Behavior unchanged for X-Agent-Key clients and for requests from untrusted source IPs — the proxy-identity path is only consulted when the key header is absent. Helpers: - trusted_proxy_ips(): set parsed from API_TRUSTED_PROXY_IPS - is_trusted_proxy_request(): client.host membership check - normalize_proxy_user(): lowercase + email local-part fallback - resolve_proxy_agent(): DB lookup for active agent by name Replaces the abandoned feat/trusted-proxy-ips branch (3 weeks behind main; deleted recently-merged permissions/coherence work and conflated frontend StaticFiles serving with this work). This change carries only the proxy-identity primitives; no StaticFiles, no FileResponse — the SPA is now served by Caddy from /var/www/delega and the backend stays API-only. Tests: 6 cases in tests/test_trusted_proxy.py covering accept (Remote-User, X-Forwarded-User, email form) and reject (untrusted source IP, unknown user, inactive agent). Full suite: 56 passed. Co-authored-by: Marty (Clawdbot) <marty@mcmillan.io> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent bca6b47 commit daf84a2

2 files changed

Lines changed: 219 additions & 0 deletions

File tree

backend/main.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@ def env_flag(name: str, default: bool) -> bool:
4242
if o.strip()
4343
]
4444

45+
46+
def trusted_proxy_ips() -> set[str]:
47+
raw = os.environ.get("API_TRUSTED_PROXY_IPS", "")
48+
return {term.strip() for term in raw.split(",") if term.strip()}
49+
50+
4551
from apscheduler.schedulers.background import BackgroundScheduler
4652

4753
from database import engine, get_db, SessionLocal, Base
@@ -275,6 +281,40 @@ def require_localhost_target(request: Request, detail: str) -> None:
275281
raise HTTPException(status_code=403, detail=detail)
276282

277283

284+
def is_trusted_proxy_request(request: Request) -> bool:
285+
client_host = request.client.host if request.client else ""
286+
if not client_host:
287+
return False
288+
return client_host in trusted_proxy_ips()
289+
290+
291+
def normalize_proxy_user(raw_value: Optional[str]) -> list[str]:
292+
if not raw_value:
293+
return []
294+
value = raw_value.strip().lower()
295+
if not value:
296+
return []
297+
candidates = [value]
298+
if "@" in value:
299+
local_part = value.split("@", 1)[0]
300+
if local_part and local_part not in candidates:
301+
candidates.append(local_part)
302+
return candidates
303+
304+
305+
def resolve_proxy_agent(db: Session, request: Request) -> Optional[models.Agent]:
306+
if not is_trusted_proxy_request(request):
307+
return None
308+
proxy_user = request.headers.get("Remote-User") or request.headers.get("X-Forwarded-User")
309+
candidates = normalize_proxy_user(proxy_user)
310+
if not candidates:
311+
return None
312+
return db.query(models.Agent).filter(
313+
models.Agent.active == True,
314+
models.Agent.name.in_(candidates),
315+
).first()
316+
317+
278318
def is_initial_agent_bootstrap_request(request: Request) -> bool:
279319
return request.method.upper() == "POST" and request.url.path == "/api/agents"
280320

@@ -499,6 +539,19 @@ async def auth_gate_middleware(request: Request, call_next):
499539
if not x_agent_key:
500540
if REQUIRE_AUTH and allow_initial_agent_bootstrap(request):
501541
return await call_next(request)
542+
db = SessionLocal()
543+
try:
544+
agent = resolve_proxy_agent(db, request)
545+
if agent:
546+
agent.last_seen_at = datetime.now(timezone.utc)
547+
db.commit()
548+
request.state.current_agent_id = agent.id
549+
return await call_next(request)
550+
except Exception as exc:
551+
db.rollback()
552+
logger.error("Trusted-proxy resolution error: %s", exc)
553+
finally:
554+
db.close()
502555
if REQUIRE_AUTH:
503556
return JSONResponse(
504557
status_code=401,
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
"""Tests for the trusted-proxy / Remote-User auth path.
2+
3+
When a request arrives from an IP listed in API_TRUSTED_PROXY_IPS and
4+
carries a Remote-User (or X-Forwarded-User) header naming an active
5+
agent, the auth-gate middleware accepts it without an X-Agent-Key.
6+
This is the contract that lets Authelia (in front of Caddy) hand off
7+
identity to the backend on behalf of browser SSO sessions.
8+
9+
Untrusted source IPs sending the same header must be rejected.
10+
"""
11+
12+
import os
13+
import sys
14+
import pytest
15+
from pathlib import Path
16+
17+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
18+
19+
os.environ["DELEGA_REQUIRE_AUTH"] = "true"
20+
os.environ["DELEGA_DB_PATH"] = ":memory:"
21+
22+
from sqlalchemy import create_engine
23+
from sqlalchemy.orm import sessionmaker
24+
from fastapi.testclient import TestClient
25+
26+
import main
27+
import models
28+
from main import app, get_db, derive_key_hash, derive_key_lookup
29+
30+
31+
@pytest.fixture(autouse=True)
32+
def fresh_db(tmp_path):
33+
db_path = str(tmp_path / "test.db")
34+
test_engine = create_engine(
35+
f"sqlite:///{db_path}",
36+
connect_args={"check_same_thread": False},
37+
)
38+
models.Base.metadata.create_all(bind=test_engine)
39+
TestSession = sessionmaker(autocommit=False, autoflush=False, bind=test_engine)
40+
41+
def override_get_db():
42+
db = TestSession()
43+
try:
44+
yield db
45+
finally:
46+
db.close()
47+
48+
app.dependency_overrides[get_db] = override_get_db
49+
_orig = main.SessionLocal
50+
main.SessionLocal = TestSession
51+
main._rate_limiter._hits.clear()
52+
53+
yield test_engine
54+
55+
main.SessionLocal = _orig
56+
app.dependency_overrides.clear()
57+
58+
59+
def make_agent(engine, name: str):
60+
import secrets as _secrets
61+
raw_key = "dlg_" + _secrets.token_urlsafe(32)
62+
salt = _secrets.token_hex(16)
63+
Session = sessionmaker(bind=engine)
64+
db = Session()
65+
agent = models.Agent(
66+
name=name,
67+
api_key=raw_key,
68+
key_hash=derive_key_hash(raw_key, salt),
69+
key_salt=salt,
70+
key_lookup=derive_key_lookup(raw_key),
71+
is_admin=False,
72+
permissions=[],
73+
active=True,
74+
)
75+
db.add(agent)
76+
db.commit()
77+
db.refresh(agent)
78+
db.close()
79+
return agent.name
80+
81+
82+
class TestTrustedProxyIdentity:
83+
def test_remote_user_from_trusted_proxy_authenticates(self, fresh_db, monkeypatch):
84+
agent_name = make_agent(fresh_db, "marty")
85+
monkeypatch.setenv("API_TRUSTED_PROXY_IPS", "192.168.10.220,127.0.0.1")
86+
client = TestClient(
87+
app, base_url="http://localhost", client=("192.168.10.220", 50001)
88+
)
89+
try:
90+
r = client.get("/api/tasks", headers={"Remote-User": agent_name})
91+
assert r.status_code == 200, r.text
92+
finally:
93+
client.close()
94+
95+
def test_x_forwarded_user_also_accepted(self, fresh_db, monkeypatch):
96+
agent_name = make_agent(fresh_db, "doc")
97+
monkeypatch.setenv("API_TRUSTED_PROXY_IPS", "192.168.10.220")
98+
client = TestClient(
99+
app, base_url="http://localhost", client=("192.168.10.220", 50002)
100+
)
101+
try:
102+
r = client.get("/api/tasks", headers={"X-Forwarded-User": agent_name})
103+
assert r.status_code == 200, r.text
104+
finally:
105+
client.close()
106+
107+
def test_email_form_resolves_to_local_part(self, fresh_db, monkeypatch):
108+
make_agent(fresh_db, "biff")
109+
monkeypatch.setenv("API_TRUSTED_PROXY_IPS", "192.168.10.220")
110+
client = TestClient(
111+
app, base_url="http://localhost", client=("192.168.10.220", 50003)
112+
)
113+
try:
114+
r = client.get("/api/tasks", headers={"Remote-User": "biff@mcmillan.io"})
115+
assert r.status_code == 200, r.text
116+
finally:
117+
client.close()
118+
119+
def test_untrusted_source_ip_is_rejected(self, fresh_db, monkeypatch):
120+
make_agent(fresh_db, "george")
121+
monkeypatch.setenv("API_TRUSTED_PROXY_IPS", "192.168.10.220")
122+
client = TestClient(
123+
app, base_url="http://localhost", client=("192.168.10.221", 50004)
124+
)
125+
try:
126+
r = client.get("/api/tasks", headers={"Remote-User": "george"})
127+
assert r.status_code == 401
128+
finally:
129+
client.close()
130+
131+
def test_unknown_user_from_trusted_proxy_is_rejected(self, fresh_db, monkeypatch):
132+
monkeypatch.setenv("API_TRUSTED_PROXY_IPS", "192.168.10.220")
133+
client = TestClient(
134+
app, base_url="http://localhost", client=("192.168.10.220", 50005)
135+
)
136+
try:
137+
r = client.get("/api/tasks", headers={"Remote-User": "ghost"})
138+
assert r.status_code == 401
139+
finally:
140+
client.close()
141+
142+
def test_inactive_agent_from_trusted_proxy_is_rejected(self, fresh_db, monkeypatch):
143+
Session = sessionmaker(bind=fresh_db)
144+
db = Session()
145+
agent = models.Agent(
146+
name="strickland",
147+
api_key="dlg_xx",
148+
key_hash="x",
149+
key_salt="x",
150+
key_lookup="x",
151+
is_admin=False,
152+
permissions=[],
153+
active=False,
154+
)
155+
db.add(agent)
156+
db.commit()
157+
db.close()
158+
monkeypatch.setenv("API_TRUSTED_PROXY_IPS", "192.168.10.220")
159+
client = TestClient(
160+
app, base_url="http://localhost", client=("192.168.10.220", 50006)
161+
)
162+
try:
163+
r = client.get("/api/tasks", headers={"Remote-User": "strickland"})
164+
assert r.status_code == 401
165+
finally:
166+
client.close()

0 commit comments

Comments
 (0)