refactor: centralize authorization checks in OpenClawAuthorizationPolicy
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
168
backend/app/services/openclaw/policies.py
Normal file
168
backend/app/services/openclaw/policies.py
Normal 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
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user