This commit is contained in:
Jonas Bengtsson
2026-08-30 11:30:09 +02:00
parent 47714fa7e3
commit c139b30f6f
28 changed files with 2100 additions and 34 deletions
+54 -11
View File
@@ -16,6 +16,8 @@ from psycopg import sql
from psycopg.rows import dict_row
from psycopg_pool import ConnectionPool
from shared.access import COOKIE_NAME, Identity, hash_token, load_identity, topic_conditions
_SELECT_SQL: Final = "SELECT time, topic, device_id, payload FROM telemetry"
# Connections use row_factory=dict_row, so each row is a dict.
@@ -63,17 +65,33 @@ def get_pool() -> ConnectionPool[_DictConnection]:
return pool
@app.middleware("http")
async def require_client_cert(request: Request, call_next):
def _request_identity(request: Request) -> Optional[Identity]:
"""Resolve the request principal: mTLS cert, session cookie, or None."""
# uvicorn populates the client cert into the transport when mTLS is enabled.
cert = getattr(request.scope.get("transport"), "get_extra_info", lambda _: None)(
"peercert"
)
if cert is None:
# Return a response directly: HTTPException raised from middleware is
# not handled by the (inner) exception handlers and would surface
# as a 500.
return JSONResponse(status_code=401, content={"detail": "Client certificate required"})
if cert is not None:
return Identity.service(cert)
token = request.cookies.get(COOKIE_NAME)
if not token:
return None
conn = get_pool().getconn()
try:
return load_identity(conn, hash_token(token))
finally:
get_pool().putconn(conn)
@app.middleware("http")
async def require_client_cert(request: Request, call_next):
# Return a response directly: HTTPException raised from middleware is
# not handled by the (inner) exception handlers and would surface
# as a 500.
identity = _request_identity(request)
if identity is None:
return JSONResponse(status_code=401, content={"detail": "Authentication required"})
request.state.identity = identity
return await call_next(request)
@@ -82,15 +100,35 @@ def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/whoami")
def whoami(request: Request) -> dict[str, Any]:
"""Show the authenticated principal and effective access rules.
Useful to verify which auth path a request took (client certificate
vs session cookie) and which topics the effective rules allow.
"""
identity: Identity = request.state.identity
return {
"username": identity.username,
"role": identity.role,
"rules": {"allow": list(identity.rules.allow), "deny": list(identity.rules.deny)},
}
def _telemetry_query(
topic: Optional[str], device_id: Optional[str], limit: int
identity: Identity,
topic: Optional[str],
device_id: Optional[str],
limit: int,
) -> tuple[sql.Composed, list[Any]]:
"""Build the SELECT for the requested filters as composed SQL.
The statement is composed with psycopg.sql, and all values are bound
as parameters, so nothing is interpolated into the query string.
Access rules are folded into the WHERE clause (deny wins over allow),
so filtering happens in the database and LIMIT stays correct.
"""
conditions: list[sql.Composable] = []
conditions, param_groups = topic_conditions(identity)
params: list[Any] = []
if topic:
conditions.append(sql.SQL("topic = %s"))
@@ -98,6 +136,7 @@ def _telemetry_query(
if device_id:
conditions.append(sql.SQL("device_id = %s"))
params.append(device_id)
params.extend(p for group in param_groups for p in group)
query = sql.SQL(_SELECT_SQL)
if conditions:
query += sql.SQL(" WHERE ") + sql.SQL(" AND ").join(conditions)
@@ -108,11 +147,13 @@ def _telemetry_query(
@app.get("/telemetry")
def list_telemetry(
request: Request,
topic: Optional[str] = Query(default=None),
device_id: Optional[str] = Query(default=None),
limit: int = Query(default=100, le=1000),
) -> list[dict[str, Any]]:
query, params = _telemetry_query(topic, device_id, limit)
identity: Identity = request.state.identity
query, params = _telemetry_query(identity, topic, device_id, limit)
with get_pool().connection() as conn, conn.cursor() as cur:
cur.execute(query, params)
return cur.fetchall()
@@ -120,10 +161,12 @@ def list_telemetry(
@app.get("/telemetry/latest")
def latest_telemetry(
request: Request,
topic: Optional[str] = Query(default=None),
device_id: Optional[str] = Query(default=None),
) -> Optional[dict[str, Any]]:
query, params = _telemetry_query(topic, device_id, 1)
identity: Identity = request.state.identity
query, params = _telemetry_query(identity, topic, device_id, 1)
with get_pool().connection() as conn, conn.cursor() as cur:
cur.execute(query, params)
row = cur.fetchone()