refactor: centralize authorization checks in OpenClawAuthorizationPolicy

This commit is contained in:
Abhimanyu Saharan
2026-02-10 15:44:49 +05:30
parent e75b2844bb
commit 39eca909a2
6 changed files with 280 additions and 101 deletions

View File

@@ -46,6 +46,7 @@ from app.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskCreate, Ta
from app.services.activity_log import record_activity from app.services.activity_log import record_activity
from app.services.openclaw.agent_service import AgentLifecycleService from app.services.openclaw.agent_service import AgentLifecycleService
from app.services.openclaw.coordination_service import GatewayCoordinationService from app.services.openclaw.coordination_service import GatewayCoordinationService
from app.services.openclaw.policies import OpenClawAuthorizationPolicy
from app.services.task_dependencies import ( from app.services.task_dependencies import (
blocked_by_dependency_ids, blocked_by_dependency_ids,
dependency_status_by_id, dependency_status_by_id,
@@ -120,8 +121,22 @@ def _actor(agent_ctx: AgentAuthContext) -> ActorContext:
def _guard_board_access(agent_ctx: AgentAuthContext, board: Board) -> None: def _guard_board_access(agent_ctx: AgentAuthContext, board: Board) -> None:
if agent_ctx.agent.board_id and agent_ctx.agent.board_id != board.id: allowed = not (agent_ctx.agent.board_id and agent_ctx.agent.board_id != board.id)
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) OpenClawAuthorizationPolicy.require_board_write_access(allowed=allowed)
def _require_board_lead(agent_ctx: AgentAuthContext) -> Agent:
return OpenClawAuthorizationPolicy.require_board_lead_actor(
actor_agent=agent_ctx.agent,
detail="Only board leads can perform this action",
)
def _guard_task_access(agent_ctx: AgentAuthContext, task: Task) -> None:
allowed = not (
agent_ctx.agent.board_id and task.board_id and agent_ctx.agent.board_id != task.board_id
)
OpenClawAuthorizationPolicy.require_board_write_access(allowed=allowed)
@router.get("/boards", response_model=DefaultLimitOffsetPage[BoardRead]) @router.get("/boards", response_model=DefaultLimitOffsetPage[BoardRead])
@@ -156,8 +171,10 @@ async def list_agents(
"""List agents, optionally filtered to a board.""" """List agents, optionally filtered to a board."""
statement = select(Agent) statement = select(Agent)
if agent_ctx.agent.board_id: if agent_ctx.agent.board_id:
if board_id and board_id != agent_ctx.agent.board_id: if board_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) OpenClawAuthorizationPolicy.require_board_write_access(
allowed=board_id == agent_ctx.agent.board_id,
)
statement = statement.where(Agent.board_id == agent_ctx.agent.board_id) statement = statement.where(Agent.board_id == agent_ctx.agent.board_id)
elif board_id: elif board_id:
statement = statement.where(Agent.board_id == board_id) statement = statement.where(Agent.board_id == board_id)
@@ -203,8 +220,7 @@ async def create_task(
) -> TaskRead: ) -> TaskRead:
"""Create a task on the board as the lead agent.""" """Create a task on the board as the lead agent."""
_guard_board_access(agent_ctx, board) _guard_board_access(agent_ctx, board)
if not agent_ctx.agent.is_board_lead: _require_board_lead(agent_ctx)
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
data = payload.model_dump(exclude={"depends_on_task_ids"}) data = payload.model_dump(exclude={"depends_on_task_ids"})
depends_on_task_ids = list(payload.depends_on_task_ids) depends_on_task_ids = list(payload.depends_on_task_ids)
@@ -297,8 +313,7 @@ async def update_task(
agent_ctx: AgentAuthContext = AGENT_CTX_DEP, agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> TaskRead: ) -> TaskRead:
"""Update a task after board-level access checks.""" """Update a task after board-level access checks."""
if agent_ctx.agent.board_id and task.board_id and agent_ctx.agent.board_id != task.board_id: _guard_task_access(agent_ctx, task)
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
return await tasks_api.update_task( return await tasks_api.update_task(
payload=payload, payload=payload,
task=task, task=task,
@@ -317,8 +332,7 @@ async def list_task_comments(
agent_ctx: AgentAuthContext = AGENT_CTX_DEP, agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> LimitOffsetPage[TaskCommentRead]: ) -> LimitOffsetPage[TaskCommentRead]:
"""List comments for a task visible to the authenticated agent.""" """List comments for a task visible to the authenticated agent."""
if agent_ctx.agent.board_id and task.board_id and agent_ctx.agent.board_id != task.board_id: _guard_task_access(agent_ctx, task)
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
return await tasks_api.list_task_comments( return await tasks_api.list_task_comments(
task=task, task=task,
session=session, session=session,
@@ -336,8 +350,7 @@ async def create_task_comment(
agent_ctx: AgentAuthContext = AGENT_CTX_DEP, agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> ActivityEvent: ) -> ActivityEvent:
"""Create a task comment on behalf of the authenticated agent.""" """Create a task comment on behalf of the authenticated agent."""
if agent_ctx.agent.board_id and task.board_id and agent_ctx.agent.board_id != task.board_id: _guard_task_access(agent_ctx, task)
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
return await tasks_api.create_task_comment( return await tasks_api.create_task_comment(
payload=payload, payload=payload,
task=task, task=task,
@@ -444,12 +457,9 @@ async def create_agent(
agent_ctx: AgentAuthContext = AGENT_CTX_DEP, agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> AgentRead: ) -> AgentRead:
"""Create an agent on the caller's board.""" """Create an agent on the caller's board."""
if not agent_ctx.agent.is_board_lead: lead = _require_board_lead(agent_ctx)
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
if not agent_ctx.agent.board_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
payload = AgentCreate( payload = AgentCreate(
**{**payload.model_dump(), "board_id": agent_ctx.agent.board_id}, **{**payload.model_dump(), "board_id": lead.board_id},
) )
return await agents_api.create_agent( return await agents_api.create_agent(
payload=payload, payload=payload,
@@ -468,8 +478,7 @@ async def nudge_agent(
) -> OkResponse: ) -> OkResponse:
"""Send a direct nudge message to a board agent.""" """Send a direct nudge message to a board agent."""
_guard_board_access(agent_ctx, board) _guard_board_access(agent_ctx, board)
if not agent_ctx.agent.is_board_lead: _require_board_lead(agent_ctx)
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
coordination = GatewayCoordinationService(session) coordination = GatewayCoordinationService(session)
await coordination.nudge_board_agent( await coordination.nudge_board_agent(
board=board, board=board,
@@ -506,8 +515,10 @@ async def get_agent_soul(
) -> str: ) -> str:
"""Fetch the target agent's SOUL.md content from the gateway.""" """Fetch the target agent's SOUL.md content from the gateway."""
_guard_board_access(agent_ctx, board) _guard_board_access(agent_ctx, board)
if not agent_ctx.agent.is_board_lead and str(agent_ctx.agent.id) != agent_id: OpenClawAuthorizationPolicy.require_board_lead_or_same_actor(
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) actor_agent=agent_ctx.agent,
target_agent_id=agent_id,
)
coordination = GatewayCoordinationService(session) coordination = GatewayCoordinationService(session)
return await coordination.get_agent_soul( return await coordination.get_agent_soul(
board=board, board=board,
@@ -526,8 +537,7 @@ async def update_agent_soul(
) -> OkResponse: ) -> OkResponse:
"""Update an agent's SOUL.md content in DB and gateway.""" """Update an agent's SOUL.md content in DB and gateway."""
_guard_board_access(agent_ctx, board) _guard_board_access(agent_ctx, board)
if not agent_ctx.agent.is_board_lead: _require_board_lead(agent_ctx)
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
coordination = GatewayCoordinationService(session) coordination = GatewayCoordinationService(session)
await coordination.update_agent_soul( await coordination.update_agent_soul(
board=board, board=board,
@@ -553,8 +563,7 @@ async def ask_user_via_gateway_main(
) -> GatewayMainAskUserResponse: ) -> GatewayMainAskUserResponse:
"""Route a lead's ask-user request through the dedicated gateway agent.""" """Route a lead's ask-user request through the dedicated gateway agent."""
_guard_board_access(agent_ctx, board) _guard_board_access(agent_ctx, board)
if not agent_ctx.agent.is_board_lead: _require_board_lead(agent_ctx)
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
coordination = GatewayCoordinationService(session) coordination = GatewayCoordinationService(session)
return await coordination.ask_user_via_gateway_main( return await coordination.ask_user_via_gateway_main(
board=board, board=board,

View File

@@ -34,6 +34,7 @@ from app.schemas.board_onboarding import (
) )
from app.schemas.boards import BoardRead from app.schemas.boards import BoardRead
from app.services.openclaw.onboarding_service import BoardOnboardingMessagingService from app.services.openclaw.onboarding_service import BoardOnboardingMessagingService
from app.services.openclaw.policies import OpenClawAuthorizationPolicy
from app.services.openclaw.provisioning import ( from app.services.openclaw.provisioning import (
LeadAgentOptions, LeadAgentOptions,
LeadAgentRequest, LeadAgentRequest,
@@ -307,13 +308,15 @@ async def agent_onboarding_update(
if actor.actor_type != "agent" or actor.agent is None: if actor.actor_type != "agent" or actor.agent is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
agent = actor.agent agent = actor.agent
if agent.board_id is not None: OpenClawAuthorizationPolicy.require_gateway_scoped_actor(actor_agent=agent)
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
if board.gateway_id: if board.gateway_id:
gateway = await Gateway.objects.by_id(board.gateway_id).first(session) gateway = await Gateway.objects.by_id(board.gateway_id).first(session)
if gateway and (agent.gateway_id != gateway.id or agent.board_id is not None): if gateway:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) OpenClawAuthorizationPolicy.require_gateway_main_actor_binding(
actor_agent=agent,
gateway=gateway,
)
onboarding = ( onboarding = (
await BoardOnboardingSession.objects.filter_by(board_id=board.id) await BoardOnboardingSession.objects.filter_by(board_id=board.id)

View File

@@ -44,6 +44,7 @@ from app.services.openclaw.constants import (
DEFAULT_HEARTBEAT_CONFIG, DEFAULT_HEARTBEAT_CONFIG,
OFFLINE_AFTER, OFFLINE_AFTER,
) )
from app.services.openclaw.policies import OpenClawAuthorizationPolicy
from app.services.openclaw.provisioning import ( from app.services.openclaw.provisioning import (
AgentProvisionRequest, AgentProvisionRequest,
MainAgentProvisionRequest, MainAgentProvisionRequest,
@@ -500,18 +501,26 @@ class AgentLifecycleService:
write: bool, write: bool,
) -> None: ) -> None:
if agent.board_id is None: if agent.board_id is None:
if not is_org_admin(ctx.member): OpenClawAuthorizationPolicy.require_org_admin(is_admin=is_org_admin(ctx.member))
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
gateway = await self.get_main_agent_gateway(agent) gateway = await self.get_main_agent_gateway(agent)
if gateway is None or gateway.organization_id != ctx.organization.id: OpenClawAuthorizationPolicy.require_gateway_in_org(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) gateway=gateway,
organization_id=ctx.organization.id,
)
return return
board = await Board.objects.by_id(agent.board_id).first(self.session) board = await Board.objects.by_id(agent.board_id).first(self.session)
if board is None or board.organization_id != ctx.organization.id: board = OpenClawAuthorizationPolicy.require_board_in_org(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) board=board,
if not await has_board_access(self.session, member=ctx.member, board=board, write=write): organization_id=ctx.organization.id,
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) )
allowed = await has_board_access(
self.session,
member=ctx.member,
board=board,
write=write,
)
OpenClawAuthorizationPolicy.require_board_write_access(allowed=allowed)
@staticmethod @staticmethod
def record_heartbeat(session: AsyncSession, agent: Agent) -> None: def record_heartbeat(session: AsyncSession, agent: Agent) -> None:
@@ -544,27 +553,15 @@ class AgentLifecycleService:
) -> AgentCreate: ) -> AgentCreate:
if actor.actor_type == "user": if actor.actor_type == "user":
ctx = await self.require_user_context(actor.user) ctx = await self.require_user_context(actor.user)
if not is_org_admin(ctx.member): OpenClawAuthorizationPolicy.require_org_admin(is_admin=is_org_admin(ctx.member))
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
return payload return payload
if actor.actor_type == "agent": if actor.actor_type == "agent":
if not actor.agent or not actor.agent.is_board_lead: board_id = OpenClawAuthorizationPolicy.resolve_board_lead_create_board_id(
raise HTTPException( actor_agent=actor.agent,
status_code=status.HTTP_403_FORBIDDEN, requested_board_id=payload.board_id,
detail="Only board leads can create agents", )
) return AgentCreate(**{**payload.model_dump(), "board_id": board_id})
if not actor.agent.board_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Board lead must be assigned to a board",
)
if payload.board_id and payload.board_id != actor.agent.board_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Board leads can only create agents in their own board",
)
return AgentCreate(**{**payload.model_dump(), "board_id": actor.agent.board_id})
return payload return payload
@@ -718,8 +715,8 @@ class AgentLifecycleService:
updates: dict[str, Any], updates: dict[str, Any],
make_main: bool | None, make_main: bool | None,
) -> None: ) -> None:
if make_main and not is_org_admin(ctx.member): if make_main:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) OpenClawAuthorizationPolicy.require_org_admin(is_admin=is_org_admin(ctx.member))
if "status" in updates: if "status" in updates:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
@@ -727,15 +724,17 @@ class AgentLifecycleService:
) )
if "board_id" in updates and updates["board_id"] is not None: if "board_id" in updates and updates["board_id"] is not None:
new_board = await self.require_board(updates["board_id"]) new_board = await self.require_board(updates["board_id"])
if new_board.organization_id != ctx.organization.id: OpenClawAuthorizationPolicy.require_board_in_org(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) board=new_board,
if not await has_board_access( organization_id=ctx.organization.id,
)
allowed = await has_board_access(
self.session, self.session,
member=ctx.member, member=ctx.member,
board=new_board, board=new_board,
write=True, write=True,
): )
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) OpenClawAuthorizationPolicy.require_board_write_access(allowed=allowed)
async def apply_agent_update_mutations( async def apply_agent_update_mutations(
self, self,
@@ -919,8 +918,7 @@ class AgentLifecycleService:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
if actor.actor_type == "user": if actor.actor_type == "user":
ctx = await self.require_user_context(actor.user) ctx = await self.require_user_context(actor.user)
if not is_org_admin(ctx.member): OpenClawAuthorizationPolicy.require_org_admin(is_admin=is_org_admin(ctx.member))
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
board = await self.require_board( board = await self.require_board(
payload.board_id, payload.board_id,
@@ -1045,8 +1043,10 @@ class AgentLifecycleService:
ctx: OrganizationContext, ctx: OrganizationContext,
) -> LimitOffsetPage[AgentRead]: ) -> LimitOffsetPage[AgentRead]:
board_ids = await list_accessible_board_ids(self.session, member=ctx.member, write=False) board_ids = await list_accessible_board_ids(self.session, member=ctx.member, write=False)
if board_id is not None and board_id not in set(board_ids): if board_id is not None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) OpenClawAuthorizationPolicy.require_board_write_access(
allowed=board_id in set(board_ids),
)
base_filters: list[ColumnElement[bool]] = [] base_filters: list[ColumnElement[bool]] = []
if board_ids: if board_ids:
base_filters.append(col(Agent.board_id).in_(board_ids)) base_filters.append(col(Agent.board_id).in_(board_ids))
@@ -1099,8 +1099,8 @@ class AgentLifecycleService:
last_seen = since_dt last_seen = since_dt
board_ids = await list_accessible_board_ids(self.session, member=ctx.member, write=False) board_ids = await list_accessible_board_ids(self.session, member=ctx.member, write=False)
allowed_ids = set(board_ids) allowed_ids = set(board_ids)
if board_id is not None and board_id not in allowed_ids: if board_id is not None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) OpenClawAuthorizationPolicy.require_board_write_access(allowed=board_id in allowed_ids)
async def event_generator() -> AsyncIterator[dict[str, str]]: async def event_generator() -> AsyncIterator[dict[str, str]]:
nonlocal last_seen nonlocal last_seen
@@ -1258,12 +1258,14 @@ class AgentLifecycleService:
agent = await Agent.objects.by_id(agent_id).first(self.session) agent = await Agent.objects.by_id(agent_id).first(self.session)
if agent is None: if agent is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
if actor.actor_type == "agent" and actor.agent and actor.agent.id != agent.id: if actor.actor_type == "agent":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) OpenClawAuthorizationPolicy.require_same_agent_actor(
actor_agent_id=actor.agent.id if actor.agent else None,
target_agent_id=agent.id,
)
if actor.actor_type == "user": if actor.actor_type == "user":
ctx = await self.require_user_context(actor.user) ctx = await self.require_user_context(actor.user)
if not is_org_admin(ctx.member): OpenClawAuthorizationPolicy.require_org_admin(is_admin=is_org_admin(ctx.member))
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
await self.require_agent_access(agent=agent, ctx=ctx, write=True) await self.require_agent_access(agent=agent, ctx=ctx, write=True)
return await self.commit_heartbeat( return await self.commit_heartbeat(
agent=agent, agent=agent,
@@ -1301,8 +1303,11 @@ class AgentLifecycleService:
agent=agent, agent=agent,
user=actor.user, user=actor.user,
) )
elif actor.actor_type == "agent" and actor.agent and actor.agent.id != agent.id: elif actor.actor_type == "agent":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) OpenClawAuthorizationPolicy.require_same_agent_actor(
actor_agent_id=actor.agent.id if actor.agent else None,
target_agent_id=agent.id,
)
await self.ensure_heartbeat_session_key( await self.ensure_heartbeat_session_key(
agent=agent, agent=agent,

View File

@@ -35,6 +35,7 @@ from app.services.openclaw.exceptions import (
map_gateway_error_to_http_exception, map_gateway_error_to_http_exception,
) )
from app.services.openclaw.internal import agent_key, with_coordination_gateway_retry from app.services.openclaw.internal import agent_key, with_coordination_gateway_retry
from app.services.openclaw.policies import OpenClawAuthorizationPolicy
from app.services.openclaw.provisioning import ( from app.services.openclaw.provisioning import (
LeadAgentOptions, LeadAgentOptions,
LeadAgentRequest, LeadAgentRequest,
@@ -140,19 +141,12 @@ class GatewayCoordinationService(AbstractGatewayMessagingService):
self, self,
actor_agent: Agent, actor_agent: Agent,
) -> tuple[Gateway, GatewayClientConfig]: ) -> tuple[Gateway, GatewayClientConfig]:
detail = "Only the dedicated gateway agent may call this endpoint."
if actor_agent.board_id is not None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail)
gateway = await Gateway.objects.by_id(actor_agent.gateway_id).first(self.session) gateway = await Gateway.objects.by_id(actor_agent.gateway_id).first(self.session)
if gateway is None: gateway = OpenClawAuthorizationPolicy.require_gateway_main_actor_binding(
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail) actor_agent=actor_agent,
if actor_agent.openclaw_session_id != GatewayAgentIdentity.session_key(gateway): gateway=gateway,
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail) )
if not gateway.url: OpenClawAuthorizationPolicy.require_gateway_configured(gateway)
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Gateway url is required",
)
return gateway, GatewayClientConfig(url=gateway.url, token=gateway.token) return gateway, GatewayClientConfig(url=gateway.url, token=gateway.token)
async def require_gateway_board( async def require_gateway_board(
@@ -162,11 +156,10 @@ class GatewayCoordinationService(AbstractGatewayMessagingService):
board_id: UUID | str, board_id: UUID | str,
) -> Board: ) -> Board:
board = await Board.objects.by_id(board_id).first(self.session) board = await Board.objects.by_id(board_id).first(self.session)
if board is None: return OpenClawAuthorizationPolicy.require_board_in_gateway(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Board not found") board=board,
if board.gateway_id != gateway.id: gateway=gateway,
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) )
return board
async def _board_agent_or_404( async def _board_agent_or_404(
self, self,
@@ -175,9 +168,10 @@ class GatewayCoordinationService(AbstractGatewayMessagingService):
agent_id: str, agent_id: str,
) -> Agent: ) -> Agent:
target = await Agent.objects.by_id(agent_id).first(self.session) target = await Agent.objects.by_id(agent_id).first(self.session)
if target is None or (target.board_id and target.board_id != board.id): return OpenClawAuthorizationPolicy.require_board_agent_target(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) target=target,
return target board=board,
)
@staticmethod @staticmethod
def _gateway_file_content(payload: object) -> str | None: def _gateway_file_content(payload: object) -> str | None:

View File

@@ -0,0 +1,168 @@
"""OpenClaw authorization policy primitives."""
from __future__ import annotations
from typing import TYPE_CHECKING
from uuid import UUID
from fastapi import HTTPException, status
from app.services.openclaw.shared import GatewayAgentIdentity
if TYPE_CHECKING:
from app.models.agents import Agent
from app.models.boards import Board
from app.models.gateways import Gateway
class OpenClawAuthorizationPolicy:
"""Centralized authz checks for OpenClaw lifecycle and coordination actions."""
_GATEWAY_MAIN_ONLY_DETAIL = "Only the dedicated gateway agent may call this endpoint."
@staticmethod
def require_org_admin(*, is_admin: bool) -> None:
if not is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
@staticmethod
def require_same_agent_actor(
*,
actor_agent_id: UUID | None,
target_agent_id: UUID,
) -> None:
if actor_agent_id is not None and actor_agent_id != target_agent_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
@staticmethod
def require_gateway_scoped_actor(*, actor_agent: Agent) -> None:
if actor_agent.board_id is not None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
@classmethod
def require_gateway_main_actor_binding(
cls,
*,
actor_agent: Agent,
gateway: Gateway | None,
) -> Gateway:
cls.require_gateway_scoped_actor(actor_agent=actor_agent)
if gateway is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=cls._GATEWAY_MAIN_ONLY_DETAIL,
)
if actor_agent.openclaw_session_id != GatewayAgentIdentity.session_key(gateway):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=cls._GATEWAY_MAIN_ONLY_DETAIL,
)
return gateway
@staticmethod
def require_gateway_configured(gateway: Gateway) -> None:
if not gateway.url:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Gateway url is required",
)
@staticmethod
def require_gateway_in_org(
*,
gateway: Gateway | None,
organization_id: UUID,
) -> Gateway:
if gateway is None or gateway.organization_id != organization_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
return gateway
@staticmethod
def require_board_in_org(
*,
board: Board | None,
organization_id: UUID,
) -> Board:
if board is None or board.organization_id != organization_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
return board
@staticmethod
def require_board_in_gateway(
*,
board: Board | None,
gateway: Gateway,
) -> Board:
if board is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Board not found",
)
if board.gateway_id != gateway.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
return board
@staticmethod
def require_board_agent_target(
*,
target: Agent | None,
board: Board,
) -> Agent:
if target is None or (target.board_id and target.board_id != board.id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
return target
@staticmethod
def require_board_write_access(*, allowed: bool) -> None:
if not allowed:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
@staticmethod
def require_board_lead_actor(
*,
actor_agent: Agent | None,
detail: str = "Only board leads can perform this action",
) -> Agent:
if actor_agent is None or not actor_agent.is_board_lead:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=detail,
)
if not actor_agent.board_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Board lead must be assigned to a board",
)
return actor_agent
@staticmethod
def require_board_lead_or_same_actor(
*,
actor_agent: Agent,
target_agent_id: str,
) -> None:
allowed = actor_agent.is_board_lead or str(actor_agent.id) == target_agent_id
if not allowed:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
@classmethod
def resolve_board_lead_create_board_id(
cls,
*,
actor_agent: Agent | None,
requested_board_id: UUID | None,
) -> UUID:
lead = cls.require_board_lead_actor(
actor_agent=actor_agent,
detail="Only board leads can create agents",
)
lead_board_id = lead.board_id
if lead_board_id is None:
msg = "Board lead must be assigned to a board"
raise RuntimeError(msg)
if requested_board_id and requested_board_id != lead_board_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Board leads can only create agents in their own board",
)
return lead_board_id

View File

@@ -30,6 +30,7 @@ from app.schemas.gateway_api import (
GatewaySessionsResponse, GatewaySessionsResponse,
GatewaysStatusResponse, GatewaysStatusResponse,
) )
from app.services.openclaw.policies import OpenClawAuthorizationPolicy
from app.services.organizations import require_board_access from app.services.organizations import require_board_access
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -140,11 +141,7 @@ class GatewaySessionService:
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Board gateway_id is invalid", detail="Board gateway_id is invalid",
) )
if not gateway.url: OpenClawAuthorizationPolicy.require_gateway_configured(gateway)
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Gateway url is required",
)
main_agent = ( main_agent = (
await Agent.objects.filter_by(gateway_id=gateway.id) await Agent.objects.filter_by(gateway_id=gateway.id)
.filter(col(Agent.board_id).is_(None)) .filter(col(Agent.board_id).is_(None))
@@ -197,8 +194,11 @@ class GatewaySessionService:
@staticmethod @staticmethod
def _require_same_org(board: Board | None, organization_id: UUID) -> None: def _require_same_org(board: Board | None, organization_id: UUID) -> None:
if board is not None and board.organization_id != organization_id: if board is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) return
OpenClawAuthorizationPolicy.require_board_write_access(
allowed=board.organization_id == organization_id,
)
async def get_status( async def get_status(
self, self,