honcho/src/security.py

282 lines
11 KiB
Python

import datetime
import logging
from typing import Annotated
import jwt
from fastapi import Depends, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, Field
from src.config import settings
from src.utils.formatting import parse_datetime_iso, utc_now_iso
from .exceptions import AuthenticationException
logger = logging.getLogger(__name__)
security = HTTPBearer(
auto_error=False,
)
#
# jwt params
# all optional, used to produce tokens valid for different routes
# hierarchy: app > user > ( session / collection )
# routes that involve a 'name' parameter require permissions for the parent object
# name routes are considered 'queries' as names are mutable properties
#
# note: add routes without parameters that assume the most immediately scoped key is providing
#
class JWTParams(BaseModel):
"""
JWT parameters used to produce tokens valid for different routes.
Workspaces are the top level of the hierarchy -- a workspace key will
give access to all peers/sessions/collections in that workspace.
A session key will allow the listing and creation of messages in
that session.
A peer key will allow the listing and creation of peer-level messages
and querying the peer's dialectic endpoint.
Names shortened to minimize token size. Timestamp is included
so that many unique tokens can be generated for the same resource.
Note that the timestamp itself is not used for security, and can
be omitted, such as when Honcho generates the initial admin JWT.
Fields (all optional other than `t`):
`t`: a string timestamp of when the JWT was created
`exp`: a string timestamp of when the JWT expires (optional)
`ad`: a boolean flag indicating if the JWT is an admin JWT
`w`: (string) workspace name
`p`: (string) peer name
`s`: (string) session name
"""
t: str = Field(default_factory=utc_now_iso)
exp: str | None = None
ad: bool | None = None
w: str | None = None
p: str | None = None
s: str | None = None
def create_admin_jwt() -> str:
"""Create a JWT for admin operations."""
params = JWTParams(t="", ad=True)
key = create_jwt(params)
return key
def create_jwt(params: JWTParams) -> str:
"""Create a JWT from the given parameters."""
payload = {k: v for k, v in params.__dict__.items() if v is not None}
if not settings.AUTH.JWT_SECRET:
raise ValueError("AUTH_JWT_SECRET is not set, cannot create JWT.")
return jwt.encode(
payload, settings.AUTH.JWT_SECRET.encode("utf-8"), algorithm="HS256"
)
def scope_requires_workspace(
*, peer: str | None, session: str | None, workspace: str | None
) -> bool:
"""Return whether a peer- or session-scoped claim lacks its parent workspace.
A peer or session scope is meaningless without a workspace: the route-level
check cannot rule out cross-workspace use (a ``{p: "alice"}`` token would
match ``alice`` in any workspace). Truthiness-based so empty-string claims
count as absent. Shared by `verify_jwt` (the token-shape invariant) and the
keys API (the creation-time guard) so the two rules cannot drift apart.
Args:
peer: The peer claim, if any.
session: The session claim, if any.
workspace: The workspace claim, if any.
Returns:
True when a peer/session scope is present but the workspace is not.
"""
return bool(peer or session) and not workspace
def verify_jwt(token: str) -> JWTParams:
"""Verify a JWT and return the decoded parameters."""
params = JWTParams()
try:
if not settings.AUTH.JWT_SECRET:
raise ValueError("AUTH_JWT_SECRET is not set, cannot verify JWT.")
decoded = jwt.decode(
token, settings.AUTH.JWT_SECRET.encode("utf-8"), algorithms=["HS256"]
)
if "t" in decoded:
params.t = decoded["t"]
if "exp" in decoded:
params.exp = decoded["exp"]
if params.exp:
exp_time = parse_datetime_iso(params.exp)
current_time = datetime.datetime.now(datetime.timezone.utc)
if exp_time < current_time:
raise AuthenticationException("JWT expired")
if "ad" in decoded:
params.ad = decoded["ad"]
# Normalize empty-string scope claims to None so a blank `w`/`p`/`s`
# cannot masquerade as a present claim in the checks below.
if "w" in decoded:
params.w = decoded["w"] or None
if "p" in decoded:
params.p = decoded["p"] or None
if "s" in decoded:
params.s = decoded["s"] or None
# Token-shape invariant: a peer- or session-scoped token MUST also
# carry its parent workspace, otherwise the route-level check cannot
# rule out cross-workspace use.
if scope_requires_workspace(
peer=params.p, session=params.s, workspace=params.w
):
raise AuthenticationException(
"Invalid JWT scope: peer/session token missing workspace"
)
return params
except jwt.PyJWTError:
raise AuthenticationException("Invalid JWT") from None
def require_auth(
admin: bool | None = None,
workspace_name: str | None = None,
peer_name: str | None = None,
session_name: str | None = None,
allow_member_read: bool = False,
):
"""
Generate a dependency that requires authentication for the given parameters.
Set `allow_member_read=True` on read-only session routes to additionally
grant access to peer-scoped keys whose peer is an active member of the
session. Never set it on routes that mutate state.
"""
async def auth_dependency(
request: Request,
credentials: HTTPAuthorizationCredentials = Depends(security),
):
workspace_name_param = (
request.path_params.get(workspace_name)
or request.query_params.get(workspace_name)
if workspace_name
else None
)
peer_name_param = (
request.path_params.get(peer_name) or request.query_params.get(peer_name)
if peer_name
else None
)
session_name_param = (
request.path_params.get(session_name)
or request.query_params.get(session_name)
if session_name
else None
)
return await auth(
credentials=credentials,
admin=admin,
workspace_name=workspace_name_param,
peer_name=peer_name_param,
session_name=session_name_param,
allow_member_read=allow_member_read,
)
# Tag the closure so route-policy tests can introspect which routes opt into
# member read without re-deriving it from HTTP method (an unreliable
# read/write signal here — some read routes use POST for a richer body).
auth_dependency.honcho_allow_member_read = allow_member_read # pyright: ignore[reportFunctionMemberAccess]
return auth_dependency
async def auth(
credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)],
admin: bool | None = None,
workspace_name: str | None = None,
peer_name: str | None = None,
session_name: str | None = None,
allow_member_read: bool = False,
) -> JWTParams:
"""Authenticate the given JWT and return the decoded parameters."""
if not settings.AUTH.USE_AUTH:
return JWTParams(t="", ad=True)
if not credentials or not credentials.credentials:
logger.warning("No access token provided")
raise AuthenticationException("No access token provided")
jwt_params = verify_jwt(credentials.credentials)
# Authorize by the token's narrowest scope, not by the route's. A
# narrower-than-workspace token must NOT fall back to workspace access:
# `{w: ws, p: alice}` may only act on `alice`, never on a sibling peer.
if jwt_params.ad:
return jwt_params
if admin:
raise AuthenticationException("Resource requires admin privileges")
if not any([session_name, peer_name, workspace_name]):
# Self-authorizing routes decode the token here and compare the claims
# against body/path data inside the handler. This is needed for routes
# whose resource identifier is not available to require_auth().
return jwt_params
# Every scoped, non-admin path requires the token's workspace to match the
# route's. Check it once here so no individual branch below can forget it
# and silently re-open cross-workspace access (the bug this module fixes).
if workspace_name and jwt_params.w != workspace_name:
raise AuthenticationException("JWT not permissioned for this resource")
if jwt_params.s is not None:
# Session-scoped token: confined to its own session. It gets no
# cross-scope access to peer routes.
if not session_name or jwt_params.s != session_name:
raise AuthenticationException("JWT not permissioned for this resource")
return jwt_params
if jwt_params.p is not None:
# Peer-scoped token: its own peer routes...
if peer_name and jwt_params.p == peer_name:
return jwt_params
# ...plus read-only access to the sessions the peer is a member of.
# Gated on `allow_member_read` so only read routes opt in; writes stay
# denied. Requires the route's workspace so the membership lookup is
# scoped (every session route declares workspace_name); the workspace
# match itself was already verified above.
if allow_member_read and session_name and workspace_name:
# Lazy imports avoid an import cycle with the crud/db layers and
# keep this DB round-trip off the common (same-scope) auth path.
from src.crud.session import is_peer_in_session
from src.dependencies import tracked_db
# Membership is read on a separate committed-only (read_only)
# connection, so a peer added to the session in a not-yet-committed
# transaction reads as a non-member: writes must commit before a
# member-scoped read. Fails closed.
async with tracked_db(
"auth.is_peer_in_session", read_only=True
) as member_db:
is_member = await is_peer_in_session(
member_db, workspace_name, session_name, jwt_params.p
)
if is_member:
return jwt_params
raise AuthenticationException("JWT not permissioned for this resource")
if jwt_params.w is not None:
# Workspace tokens reach any route inside their workspace (the workspace
# match was verified above). Routes without a declared workspace (e.g.
# POST /v3/workspaces) self-authorize by reading jwt_params.w themselves.
return jwt_params
raise AuthenticationException("JWT not permissioned for this resource")