Files
pocketpaw/ee/cloud/chat/group_service.py
Rohit Kushwaha d1b5870278 feat(ee-cloud): workspace RBAC consolidation + invite flow + visibility defaults
Consolidates the two parallel RBAC frameworks into a single route-level
authorization system and adds the missing pieces needed for a managed-onboarding
pilot: an endpoint to list pending invites, sane visibility defaults for
pockets/groups so invited teammates actually see shared work, and audit
logging on every denial.

RBAC consolidation
- New ACTIONS table (src/pocketpaw/ee/guards/actions.py) is the single source
  of truth for every guarded action → (minimum role, stable deny code).
  Covers workspace, group, message, pocket, agent, session, KB, invite,
  and billing — 32 rows total.
- New audit helpers (src/pocketpaw/ee/guards/audit.py): log_denial() +
  log_privileged_action() backed by the existing append-only audit log.
- New FastAPI deps: require_action(), require_action_any_workspace(),
  require_group_action(), require_membership(), require_agent_owner_or_admin(),
  require_pocket_edit(), require_pocket_owner().
- Route-level enforcement across workspace/chat/pockets/agents/sessions/kb
  routers; permission checks removed from service bodies where now redundant.
- Group role model extended to 3 tiers (owner > admin > member), with per-member
  override via Group.member_roles. Admin tier enforced via resolve_group_role()
  and the updated _require_group_admin helper.
- Legacy ee/cloud/shared/permissions.py deleted; legacy require_role shim
  removed from ee/cloud/shared/deps.py.
- Matrix test (tests/cloud/test_rbac_matrix.py) iterates every ACTIONS entry
  across every peer role, verifying both allow and deny paths and the exact
  Forbidden.code. Meta-test enforces coverage.
- Redundant tests/cloud/test_permissions.py removed.

Invite list endpoint
- GET /workspaces/{workspace_id}/invites returns all pending invites,
  admin-only via require_action("invite.create"). Wires the admin UX for
  listing + copy-link + revoke.

Visibility defaults
- Pocket.visibility default flipped "private" → "workspace" so new pockets
  are visible to all workspace members out of the box. Owners can tighten
  per-pocket via the Share tab.
- Group.type default flipped "public" → "private" so new groups are
  invite-only. Public channels remain explicit via type="channel" or "public".
- CreateGroupRequest and UpdateGroupRequest updated; GroupService.update_group
  now supports type changes. list_groups() treats "channel" same as "public"
  for workspace-wide readability.
- Existing tests updated to match new defaults.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 10:21:16 +05:30

554 lines
19 KiB
Python

# Refactored: Split from service.py — contains GroupService class and group-related
# helper functions. N+1 query in _group_response() fixed with batch loading for
# both members (User) and agents (AgentModel).
"""Chat domain — group business logic (CRUD, membership, agents, DMs)."""
from __future__ import annotations
import logging
import re
from typing import Literal
from beanie import PydanticObjectId
from ee.cloud.chat.schemas import (
AddGroupAgentRequest,
CreateGroupRequest,
UpdateGroupAgentRequest,
UpdateGroupRequest,
)
from ee.cloud.models.group import Group, GroupAgent, MemberRole
from ee.cloud.shared.errors import Forbidden, NotFound, ValidationError
from pocketpaw.ee.guards.actions import GroupRole
from pocketpaw.ee.guards.audit import log_denial
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _generate_slug(name: str) -> str:
"""Lowercase, replace spaces/underscores with hyphens, strip non-alnum."""
slug = name.lower().strip()
slug = re.sub(r"[\s_]+", "-", slug)
slug = re.sub(r"[^a-z0-9-]", "", slug)
slug = re.sub(r"-{2,}", "-", slug)
return slug.strip("-")
async def _group_response(group: Group) -> dict:
"""Convert a Group document to a frontend-compatible dict.
Populates member IDs -> {_id, name, email} and agent IDs ->
{_id, agent, name, role, respond_mode}.
Uses batch queries to avoid N+1 per-member / per-agent lookups.
"""
from ee.cloud.models.agent import Agent as AgentModel
from ee.cloud.models.user import User
# Batch load members
member_ids = [PydanticObjectId(uid) for uid in group.members]
users = await User.find({"_id": {"$in": member_ids}}).to_list() if member_ids else []
user_map = {str(u.id): u for u in users}
populated_members = []
for uid in group.members:
user = user_map.get(uid)
if user:
populated_members.append(
{
"_id": str(user.id),
"name": user.full_name or user.email,
"email": user.email,
"avatar": user.avatar,
}
)
else:
populated_members.append({"_id": uid, "name": uid, "email": ""})
# Batch load agents
agent_ids = [PydanticObjectId(ga.agent) for ga in group.agents]
agents = await AgentModel.find({"_id": {"$in": agent_ids}}).to_list() if agent_ids else []
agent_map = {str(a.id): a for a in agents}
populated_agents = []
for ga in group.agents:
agent_doc = agent_map.get(ga.agent)
populated_agents.append(
{
"_id": str(agent_doc.id) if agent_doc else ga.agent,
"agent": ga.agent,
"name": agent_doc.name if agent_doc else "Agent",
"uname": agent_doc.slug if agent_doc else "",
"avatar": agent_doc.avatar if agent_doc else "",
"role": ga.role,
"respond_mode": ga.respond_mode,
}
)
return {
"_id": str(group.id),
"workspace": group.workspace,
"name": group.name,
"slug": group.slug,
"description": group.description,
"type": group.type,
"icon": group.icon,
"color": group.color,
"owner": group.owner,
"members": populated_members,
"memberRoles": dict(group.member_roles),
"agents": populated_agents,
"pinnedMessages": group.pinned_messages,
"archived": group.archived,
"lastMessageAt": group.last_message_at.isoformat() if group.last_message_at else None,
"messageCount": group.message_count,
"createdAt": group.createdAt.isoformat() if group.createdAt else None,
}
def _require_group_member(group: Group, user_id: str) -> None:
"""Raise Forbidden if user is not a member of the group."""
if user_id not in group.members:
log_denial(
actor=user_id,
action="group.view",
code="group.not_member",
resource_id=str(group.id),
)
raise Forbidden("group.not_member", "You are not a member of this group")
def _require_group_admin(group: Group, user_id: str) -> None:
"""Raise Forbidden if user is not a group admin or owner.
Admin tier is derived from ``group.member_roles[user_id] == "admin"``.
The group owner is always an implicit admin.
"""
if group.owner == user_id:
return
if group.member_roles.get(user_id) == "admin":
return
log_denial(
actor=user_id,
action="group.admin",
code="group.not_admin",
resource_id=str(group.id),
)
raise Forbidden("group.not_admin", "Only group admins can perform this action")
def _role_for(group: Group, user_id: str) -> Literal["owner", "admin", "edit", "view", "none"]:
"""Return the role of a user in a group.
- "owner" if user_id == group.owner
- member_roles[user_id] if present ("admin" | "edit" | "view")
- "edit" if user is a member without an explicit role entry (back-compat default)
- "none" if user is not a member
"""
if group.owner == user_id:
return "owner"
if user_id not in group.members:
return "none"
explicit = group.member_roles.get(user_id)
if explicit in ("admin", "edit", "view"):
return explicit # type: ignore[return-value]
return "edit"
def resolve_group_role(group: Group, user_id: str) -> GroupRole:
"""Structured role resolution for the canonical guards matrix.
Raises Forbidden ``group.not_member`` if the user has no membership.
"""
raw = _role_for(group, user_id)
if raw == "none":
raise Forbidden("group.not_member", "You are not a member of this group")
return GroupRole.from_str("edit" if raw == "edit" else raw)
def _require_can_post(group: Group, user_id: str) -> None:
"""Raise Forbidden if the user's role in the group cannot post / mutate."""
role = _role_for(group, user_id)
if role == "view":
log_denial(
actor=user_id,
action="group.post",
code="group.view_only",
resource_id=str(group.id),
)
raise Forbidden("group.view_only", "You have read-only access in this group")
if role == "none":
log_denial(
actor=user_id,
action="group.post",
code="group.not_member",
resource_id=str(group.id),
)
raise Forbidden("group.not_member", "You are not a member of this group")
async def _get_group_or_404(group_id: str) -> Group:
"""Load a group by ID or raise NotFound."""
group = await Group.get(PydanticObjectId(group_id))
if not group:
raise NotFound("group", group_id)
return group
# ---------------------------------------------------------------------------
# GroupService
# ---------------------------------------------------------------------------
class GroupService:
"""Stateless service for group/channel business logic."""
@staticmethod
async def create_group(workspace_id: str, user_id: str, body: CreateGroupRequest) -> dict:
"""Create a group and add the creator as a member.
For DMs: validates exactly 2 member_ids, auto-names as "DM".
"""
if body.type == "dm":
if len(body.member_ids) != 1:
raise ValidationError(
"group.dm_requires_one_target",
"DM groups require exactly one target member_id (the other party)",
)
members = sorted({user_id, body.member_ids[0]})
name = "DM"
else:
members = list({user_id, *body.member_ids})
name = body.name
slug = _generate_slug(name)
group = Group(
workspace=workspace_id,
name=name,
slug=slug,
description=body.description,
type=body.type,
icon=body.icon,
color=body.color,
members=members,
owner=user_id,
)
await group.insert()
return await _group_response(group)
@staticmethod
async def list_groups(workspace_id: str, user_id: str) -> list[dict]:
"""List groups visible to the user.
Returns public groups in the workspace plus private/dm groups
where the user is a member.
"""
groups = await Group.find(
{
"workspace": workspace_id,
"archived": False,
"$or": [
# Public groups + channels are visible to any workspace member.
# Private groups + DMs require membership.
{"type": {"$in": ["public", "channel"]}},
{"members": user_id},
],
}
).to_list()
return [await _group_response(g) for g in groups]
@staticmethod
async def get_group(group_id: str, user_id: str) -> dict:
"""Get a single group. Private/DM groups require membership."""
group = await _get_group_or_404(group_id)
if group.type in ("private", "dm"):
_require_group_member(group, user_id)
return await _group_response(group)
@staticmethod
async def update_group(group_id: str, user_id: str, body: UpdateGroupRequest) -> dict:
"""Update group fields. Owner only. Cannot update DMs."""
group = await _get_group_or_404(group_id)
if group.type == "dm":
raise Forbidden("group.cannot_update_dm", "DM groups cannot be updated")
_require_group_admin(group, user_id)
if body.name is not None:
group.name = body.name
group.slug = _generate_slug(body.name)
if body.description is not None:
group.description = body.description
if body.icon is not None:
group.icon = body.icon
if body.color is not None:
group.color = body.color
if body.type is not None and body.type != group.type:
# DMs can't change type; enforced above. Switching between
# private/public/channel just changes who can read.
group.type = body.type
await group.save()
return await _group_response(group)
@staticmethod
async def archive_group(group_id: str, user_id: str) -> None:
"""Archive a group. Owner only."""
group = await _get_group_or_404(group_id)
_require_group_admin(group, user_id)
group.archived = True
await group.save()
@staticmethod
async def join_group(group_id: str, user_id: str) -> None:
"""Join a public group. Adds user to members list."""
group = await _get_group_or_404(group_id)
if group.type != "public":
raise Forbidden("group.not_public", "Only public groups can be joined directly")
if group.archived:
raise Forbidden("group.archived", "Cannot join an archived group")
if user_id not in group.members:
group.members.append(user_id)
await group.save()
@staticmethod
async def leave_group(group_id: str, user_id: str) -> None:
"""Leave a group. Owner cannot leave (must transfer ownership first)."""
group = await _get_group_or_404(group_id)
_require_group_member(group, user_id)
if group.owner == user_id:
raise Forbidden(
"group.owner_cannot_leave",
"The group owner cannot leave. Transfer ownership first.",
)
group.members.remove(user_id)
await group.save()
@staticmethod
async def add_members(
group_id: str,
user_id: str,
member_ids: list[str],
role: MemberRole = "edit",
) -> list[str]:
"""Add members to a group with an initial role. Owner only.
Returns the list of user IDs that were newly added (skipping duplicates).
Role "edit" is the default (no role entry is written to keep the dict
small); "view" writes an explicit entry per added member.
"""
group = await _get_group_or_404(group_id)
_require_group_admin(group, user_id)
if group.archived:
raise Forbidden("group.archived", "Cannot modify an archived group")
newly_added: list[str] = []
for mid in member_ids:
if mid not in group.members:
group.members.append(mid)
newly_added.append(mid)
if role in ("admin", "view"):
group.member_roles[mid] = role
elif mid in group.member_roles and role == "edit":
# Explicit edit removes any lingering admin/view entry
group.member_roles.pop(mid, None)
if newly_added or role in ("admin", "view"):
await group.save()
return newly_added
@staticmethod
async def remove_member(group_id: str, user_id: str, target_user_id: str) -> None:
"""Remove a member from a group. Owner only. Cannot remove the owner."""
group = await _get_group_or_404(group_id)
_require_group_admin(group, user_id)
if target_user_id == group.owner:
raise Forbidden("group.cannot_remove_owner", "Cannot remove the group owner")
if target_user_id not in group.members:
raise NotFound("member", target_user_id)
group.members.remove(target_user_id)
group.member_roles.pop(target_user_id, None)
await group.save()
@staticmethod
async def set_member_role(
group_id: str, user_id: str, target_user_id: str, role: MemberRole
) -> MemberRole:
"""Set a member's role to "edit" or "view". Owner only.
Cannot change the owner's role. Raises NotFound if target is not a member.
Returns the new role on success.
"""
if role not in ("admin", "edit", "view"):
raise ValidationError(
"group.invalid_role",
f"Role must be one of 'admin', 'edit', 'view'; got {role!r}",
)
group = await _get_group_or_404(group_id)
_require_group_admin(group, user_id)
if target_user_id == group.owner:
raise Forbidden("group.cannot_change_owner_role", "Cannot change the owner's role")
if target_user_id not in group.members:
raise NotFound("member", target_user_id)
if role == "edit":
group.member_roles.pop(target_user_id, None)
else:
group.member_roles[target_user_id] = role
await group.save()
return role
@staticmethod
async def add_agent(group_id: str, user_id: str, body: AddGroupAgentRequest) -> None:
"""Add an agent to a group. Owner only."""
group = await _get_group_or_404(group_id)
_require_group_admin(group, user_id)
# Check if agent is already in the group
for existing in group.agents:
if existing.agent == body.agent_id:
raise ValidationError(
"group.agent_already_added",
f"Agent '{body.agent_id}' is already in this group",
)
group.agents.append(
GroupAgent(
agent=body.agent_id,
role=body.role,
respond_mode=body.respond_mode,
)
)
await group.save()
@staticmethod
async def update_agent(
group_id: str, user_id: str, agent_id: str, body: UpdateGroupAgentRequest
) -> None:
"""Update an agent's respond_mode in a group. Owner only."""
group = await _get_group_or_404(group_id)
_require_group_admin(group, user_id)
for agent in group.agents:
if agent.agent == agent_id:
agent.respond_mode = body.respond_mode
await group.save()
return
raise NotFound("agent", agent_id)
@staticmethod
async def remove_agent(group_id: str, user_id: str, agent_id: str) -> None:
"""Remove an agent from a group. Owner only."""
group = await _get_group_or_404(group_id)
_require_group_admin(group, user_id)
original_len = len(group.agents)
group.agents = [a for a in group.agents if a.agent != agent_id]
if len(group.agents) == original_len:
raise NotFound("agent", agent_id)
await group.save()
@staticmethod
async def get_or_create_dm(workspace_id: str, user_id: str, target_user_id: str) -> dict:
"""Find an existing DM between two users, or create one.
DM groups have type="dm", sorted members, and name="DM".
"""
members = sorted([user_id, target_user_id])
existing = await Group.find_one(
{
"workspace": workspace_id,
"type": "dm",
"members": {"$all": members, "$size": len(members)},
}
)
if existing:
return await _group_response(existing)
group = Group(
workspace=workspace_id,
name="DM",
slug=_generate_slug("dm"),
type="dm",
members=members,
owner=user_id,
)
await group.insert()
return await _group_response(group)
@staticmethod
async def get_or_create_agent_dm(workspace_id: str, user_id: str, agent_id: str) -> dict:
"""Find or create a 1:1 DM between the user and an agent.
Stored as a type="dm" group with ``members=[user_id]`` and a single
``GroupAgent`` (respond_mode="auto" so the agent replies by default).
Verifies the user can see the agent (owner | workspace-visible | public).
"""
from ee.cloud.models.agent import Agent as AgentModel
# Resolve the agent and verify access
try:
agent_oid = PydanticObjectId(agent_id)
except Exception as exc: # noqa: BLE001 - surface as NotFound
raise NotFound("agent", agent_id) from exc
agent_doc = await AgentModel.get(agent_oid)
if not agent_doc:
raise NotFound("agent", agent_id)
visible = (
(agent_doc.workspace == workspace_id and agent_doc.owner == user_id)
or (agent_doc.workspace == workspace_id and agent_doc.visibility == "workspace")
or agent_doc.visibility == "public"
)
if not visible:
raise NotFound("agent", agent_id)
# Idempotent lookup: a DM in this workspace with exactly this user and this agent
existing = await Group.find_one(
{
"workspace": workspace_id,
"type": "dm",
"members": [user_id],
"agents.agent": agent_id,
}
)
if existing:
return await _group_response(existing)
group = Group(
workspace=workspace_id,
name="DM",
slug=_generate_slug("dm"),
type="dm",
members=[user_id],
agents=[GroupAgent(agent=agent_id, role="assistant", respond_mode="auto")],
owner=user_id,
)
await group.insert()
return await _group_response(group)