Files
openclaw-mission-control/backend/app/api/deps.py
Hugh Brown cc50877131 refactor: rename require_admin_auth/require_admin_or_agent to require_user_auth/require_user_or_agent
These dependencies check actor type (human user vs agent), not admin
privilege. The old names were misleading and could cause authorization
mistakes when wiring new endpoints. Renamed across all 10 consumer
files along with their local ADMIN_AUTH_DEP / ADMIN_OR_AGENT_DEP
aliases.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 23:35:10 +05:30

209 lines
7.1 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, status
from app.core.agent_auth import AgentAuthContext, 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)
AUTH_OPTIONAL_DEP = Depends(get_auth_context_optional)
AGENT_AUTH_OPTIONAL_DEP = Depends(get_agent_auth_context_optional)
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
def require_user_or_agent(
auth: AuthContext | None = AUTH_OPTIONAL_DEP,
agent_auth: AgentAuthContext | None = AGENT_AUTH_OPTIONAL_DEP,
) -> ActorContext:
"""Authorize either a human user or an authenticated agent."""
if auth is not None:
require_user_actor(auth)
return ActorContext(actor_type="user", user=auth.user)
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