Rate-limit the optional agent bearer path after user auth resolution so mixed user/agent routes no longer leave an unthrottled PBKDF2 path. Stop logging token prefixes on agent auth failures and require a locally supplied token for backend/.env.test instead of committing one. Update tests and docs to cover agent bearer fallback, configurable webhook signature headers, and the operator-facing security settings added by the hardening work. Co-Authored-By: Claude <noreply@anthropic.com>
222 lines
7.4 KiB
Python
222 lines
7.4 KiB
Python
"""Reusable FastAPI dependencies for auth and board/task access.
|
|
|
|
These dependencies are the main "policy wiring" layer for the API.
|
|
|
|
They:
|
|
- resolve the authenticated actor (human user vs agent)
|
|
- enforce organization/board access rules
|
|
- provide common "load or 404" helpers (board/task)
|
|
|
|
Why this exists:
|
|
- Keeping authorization logic centralized makes it easier to reason about (and
|
|
audit) permissions as the API surface grows.
|
|
- Some routes allow either human users or agents; others require user auth.
|
|
|
|
If you're adding a new endpoint, prefer composing from these dependencies instead
|
|
of re-implementing permission checks in the router.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from typing import TYPE_CHECKING, Literal
|
|
from uuid import UUID
|
|
|
|
from fastapi import Depends, HTTPException, Request, status
|
|
|
|
from app.core.agent_auth import get_agent_auth_context_optional
|
|
from app.core.auth import AuthContext, get_auth_context, get_auth_context_optional
|
|
from app.db.session import get_session
|
|
from app.models.boards import Board
|
|
from app.models.organizations import Organization
|
|
from app.models.tasks import Task
|
|
from app.services.admin_access import require_user_actor
|
|
from app.services.organizations import (
|
|
OrganizationContext,
|
|
ensure_member_for_user,
|
|
get_active_membership,
|
|
is_org_admin,
|
|
require_board_access,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
|
|
from app.models.agents import Agent
|
|
from app.models.users import User
|
|
|
|
AUTH_DEP = Depends(get_auth_context)
|
|
SESSION_DEP = Depends(get_session)
|
|
|
|
|
|
def require_user_auth(auth: AuthContext = AUTH_DEP) -> AuthContext:
|
|
"""Require an authenticated human user (not an agent)."""
|
|
require_user_actor(auth)
|
|
return auth
|
|
|
|
|
|
@dataclass
|
|
class ActorContext:
|
|
"""Authenticated actor context for user or agent callers."""
|
|
|
|
actor_type: Literal["user", "agent"]
|
|
user: User | None = None
|
|
agent: Agent | None = None
|
|
|
|
|
|
async def require_user_or_agent(
|
|
request: Request,
|
|
session: AsyncSession = SESSION_DEP,
|
|
) -> ActorContext:
|
|
"""Authorize either a human user or an authenticated agent.
|
|
|
|
User auth is resolved first so normal bearer-token user traffic does not
|
|
also trigger agent-token verification on mixed user/agent routes.
|
|
"""
|
|
auth = await get_auth_context_optional(
|
|
request=request,
|
|
credentials=None,
|
|
session=session,
|
|
)
|
|
if auth is not None:
|
|
require_user_actor(auth)
|
|
return ActorContext(actor_type="user", user=auth.user)
|
|
agent_auth = await get_agent_auth_context_optional(
|
|
request=request,
|
|
agent_token=request.headers.get("X-Agent-Token"),
|
|
authorization=request.headers.get("Authorization"),
|
|
session=session,
|
|
)
|
|
if agent_auth is not None:
|
|
return ActorContext(actor_type="agent", agent=agent_auth.agent)
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
|
|
|
|
|
ACTOR_DEP = Depends(require_user_or_agent)
|
|
|
|
|
|
async def require_org_member(
|
|
auth: AuthContext = AUTH_DEP,
|
|
session: AsyncSession = SESSION_DEP,
|
|
) -> OrganizationContext:
|
|
"""Resolve and require active organization membership for the current user."""
|
|
if auth.user is None:
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
|
member = await get_active_membership(session, auth.user)
|
|
if member is None:
|
|
member = await ensure_member_for_user(session, auth.user)
|
|
if member is None:
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
|
organization = await Organization.objects.by_id(member.organization_id).first(
|
|
session,
|
|
)
|
|
if organization is None:
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
|
return OrganizationContext(organization=organization, member=member)
|
|
|
|
|
|
ORG_MEMBER_DEP = Depends(require_org_member)
|
|
|
|
|
|
async def require_org_admin(
|
|
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
|
) -> OrganizationContext:
|
|
"""Require organization-admin membership privileges."""
|
|
if not is_org_admin(ctx.member):
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
|
return ctx
|
|
|
|
|
|
async def get_board_or_404(
|
|
board_id: str,
|
|
session: AsyncSession = SESSION_DEP,
|
|
) -> Board:
|
|
"""Load a board by id or raise HTTP 404."""
|
|
board = await Board.objects.by_id(board_id).first(session)
|
|
if board is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
|
return board
|
|
|
|
|
|
async def get_board_for_actor_read(
|
|
board_id: str,
|
|
session: AsyncSession = SESSION_DEP,
|
|
actor: ActorContext = ACTOR_DEP,
|
|
) -> Board:
|
|
"""Load a board and enforce actor read access."""
|
|
board = await Board.objects.by_id(board_id).first(session)
|
|
if board is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
|
if actor.actor_type == "agent":
|
|
if actor.agent and actor.agent.board_id and actor.agent.board_id != board.id:
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
|
return board
|
|
if actor.user is None:
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
|
await require_board_access(session, user=actor.user, board=board, write=False)
|
|
return board
|
|
|
|
|
|
async def get_board_for_actor_write(
|
|
board_id: str,
|
|
session: AsyncSession = SESSION_DEP,
|
|
actor: ActorContext = ACTOR_DEP,
|
|
) -> Board:
|
|
"""Load a board and enforce actor write access."""
|
|
board = await Board.objects.by_id(board_id).first(session)
|
|
if board is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
|
if actor.actor_type == "agent":
|
|
if actor.agent and actor.agent.board_id and actor.agent.board_id != board.id:
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
|
return board
|
|
if actor.user is None:
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
|
await require_board_access(session, user=actor.user, board=board, write=True)
|
|
return board
|
|
|
|
|
|
async def get_board_for_user_read(
|
|
board_id: str,
|
|
session: AsyncSession = SESSION_DEP,
|
|
auth: AuthContext = AUTH_DEP,
|
|
) -> Board:
|
|
"""Load a board and enforce authenticated-user read access."""
|
|
board = await Board.objects.by_id(board_id).first(session)
|
|
if board is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
|
if auth.user is None:
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
|
await require_board_access(session, user=auth.user, board=board, write=False)
|
|
return board
|
|
|
|
|
|
async def get_board_for_user_write(
|
|
board_id: str,
|
|
session: AsyncSession = SESSION_DEP,
|
|
auth: AuthContext = AUTH_DEP,
|
|
) -> Board:
|
|
"""Load a board and enforce authenticated-user write access."""
|
|
board = await Board.objects.by_id(board_id).first(session)
|
|
if board is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
|
if auth.user is None:
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
|
await require_board_access(session, user=auth.user, board=board, write=True)
|
|
return board
|
|
|
|
|
|
BOARD_READ_DEP = Depends(get_board_for_actor_read)
|
|
|
|
|
|
async def get_task_or_404(
|
|
task_id: UUID,
|
|
board: Board = BOARD_READ_DEP,
|
|
session: AsyncSession = SESSION_DEP,
|
|
) -> Task:
|
|
"""Load a task for a board or raise HTTP 404."""
|
|
task = await Task.objects.by_id(task_id).first(session)
|
|
if task is None or task.board_id != board.id:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
|
return task
|