Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions backend/alembic/versions/add_user_group_permission_scope.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""add_user_group_permission_scope

Revision ID: add_user_group_scope
Revises: increase_api_key_length
Create Date: 2026-04-16 22:30:00.000000

This migration adds 'user_group' enum value for specifying multiple users with permissions.
'user' remains as "only creator" (backward compatible).
'user_group' is the new "specific users" option.
"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = 'add_user_group_scope'
down_revision: Union[str, None] = 'increase_api_key_length'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
# Add 'user_group' to the permission_scope_enum enum type
op.execute("ALTER TYPE permission_scope_enum ADD VALUE IF NOT EXISTS 'user_group'")
Comment thread
zonglinZhang marked this conversation as resolved.


def downgrade() -> None:
# Note: PostgreSQL doesn't support removing enum values easily
pass
120 changes: 95 additions & 25 deletions backend/app/api/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ async def list_agents(
.where(
(AgentPermission.scope_type == "company")
| ((AgentPermission.scope_type == "user") & (AgentPermission.scope_id == current_user.id))
| ((AgentPermission.scope_type == "user_group") & (AgentPermission.scope_id == current_user.id))
)
)
permitted = select(Agent).where(Agent.id.in_(permitted_ids), Agent.tenant_id == user_tenant)
Expand Down Expand Up @@ -198,6 +199,11 @@ async def create_agent(
db: AsyncSession = Depends(get_db),
):
"""Create a new digital employee (any authenticated user)."""
# Debug: log permission data
import logging
logger = logging.getLogger(__name__)
logger.info(f"[create_agent] Received permission data: scope_type={data.permission_scope_type}, scope_ids={data.permission_scope_ids}, access_level={data.permission_access_level}")

# Check agent creation quota
from app.services.quota_guard import check_agent_creation_quota, QuotaExceeded
try:
Expand Down Expand Up @@ -270,17 +276,30 @@ async def create_agent(

# Set permissions
access_level = data.permission_access_level if data.permission_access_level in ("use", "manage") else "use"
if data.permission_scope_type not in ("company", "user"):
if data.permission_scope_type not in ("company", "user", "user_group"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Unsupported permission_scope_type")

# Validate user_group requires at least one user
if data.permission_scope_type == "user_group" and not data.permission_scope_ids:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="user_group scope requires at least one user")

if data.permission_scope_type == "company":
db.add(AgentPermission(agent_id=agent.id, scope_type="company", access_level=access_level))
elif data.permission_scope_type == "user":
# User: only creator can access
db.add(AgentPermission(agent_id=agent.id, scope_type="user", scope_id=current_user.id, access_level="manage"))
elif data.permission_scope_type == "user_group":
if data.permission_scope_ids:
for scope_id in data.permission_scope_ids:
db.add(AgentPermission(agent_id=agent.id, scope_type="user", scope_id=scope_id, access_level=access_level))
else:
# "仅自己" — insert creator as the only permitted user
db.add(AgentPermission(agent_id=agent.id, scope_type="user", scope_id=current_user.id, access_level="manage"))
# Check if we have per-user access levels
if data.user_permissions and isinstance(data.user_permissions, dict):
# Per-user permissions: {user_id: access_level}
for scope_id in data.permission_scope_ids:
user_access = data.user_permissions.get(str(scope_id), access_level)
db.add(AgentPermission(agent_id=agent.id, scope_type="user_group", scope_id=scope_id, access_level=user_access))
Comment thread
zonglinZhang marked this conversation as resolved.
else:
# Legacy: all users share the same access level
for scope_id in data.permission_scope_ids:
db.add(AgentPermission(agent_id=agent.id, scope_type="user_group", scope_id=scope_id, access_level=access_level))

await db.flush()

Expand Down Expand Up @@ -401,23 +420,38 @@ async def get_agent_permissions(

scope_type = perms[0].scope_type
scope_ids = [str(p.scope_id) for p in perms if p.scope_id]
perm_access_level = perms[0].access_level or "use"

# Resolve names for display

# Determine access_level deterministically
if scope_type == "user_group":
# For user_group, check if all users have the same access level.
# If mixed, default to 'use' (least privilege) to prevent accidental over-permissioning.
levels = set(p.access_level or "use" for p in perms)
perm_access_level = levels.pop() if len(levels) == 1 else "use"
else:
perm_access_level = perms[0].access_level or "use"

# Resolve names and access levels for display
scope_names = []
if scope_type == "user":
for sid in scope_ids:
r = await db.execute(select(User).where(User.id == uuid.UUID(sid)))
u = r.scalar_one_or_none()
if u:
scope_names.append({"id": sid, "name": u.display_name or u.username})
if scope_type == "user_group":
for perm in perms:
if perm.scope_id:
sid = str(perm.scope_id)
r = await db.execute(select(User).where(User.id == perm.scope_id))
u = r.scalar_one_or_none()
if u:
scope_names.append({
"id": sid,
"name": u.display_name or u.username,
"access_level": perm.access_level or "use"
})

return {
"scope_type": scope_type,
"scope_ids": scope_ids,
"scope_names": scope_names,
"access_level": perm_access_level,
"is_owner": is_agent_creator(current_user, agent),
"creator_id": str(agent.creator_id) if agent.creator_id else None,
}


Expand All @@ -428,18 +462,42 @@ async def update_agent_permissions(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Update agent permission scope (owner or platform_admin only)."""
agent, _access = await check_agent_access(db, current_user, agent_id)
if not is_agent_creator(current_user, agent):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only owner or admin can change permissions")
"""Update agent permission scope (owner, admin, or users with manage access)."""
# Check admin status first to bypass strict check_agent_access for org_admins
is_admin = current_user.role in ("platform_admin", "org_admin")

if is_admin:
# Admins can manage permissions for any agent in their tenant
result = await db.execute(select(Agent).where(Agent.id == agent_id))
agent = result.scalar_one_or_none()
if not agent:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Agent not found")
# Tenant isolation check for admins
if agent.tenant_id != current_user.tenant_id and current_user.role != "platform_admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No access to this agent")
access_level = "manage" # Admins implicitly have manage access
else:
# For non-admins, use standard access check
agent, access_level = await check_agent_access(db, current_user, agent_id)

# Check if user has permission to modify
is_owner = is_agent_creator(current_user, agent)
has_manage_access = access_level == "manage"

if not is_owner and not is_admin and not has_manage_access:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only owner, admin, or users with manage access can change permissions")

scope_type = data.get("scope_type", "company")
scope_ids = data.get("scope_ids", [])
access_level = data.get("access_level", "use")
if access_level not in ("use", "manage"):
access_level = "use"
if scope_type not in ("company", "user"):
if scope_type not in ("company", "user", "user_group"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Unsupported scope_type")

# Validate user_group requires at least one user
if scope_type == "user_group" and not scope_ids:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="user_group scope requires at least one user")

# Delete existing permissions
from sqlalchemy import delete as sql_delete
Expand All @@ -449,12 +507,24 @@ async def update_agent_permissions(
if scope_type == "company":
db.add(AgentPermission(agent_id=agent_id, scope_type="company", access_level=access_level))
elif scope_type == "user":
# User: only creator can access
db.add(AgentPermission(agent_id=agent_id, scope_type="user", scope_id=current_user.id, access_level="manage"))
elif scope_type == "user_group":
Comment thread
zonglinZhang marked this conversation as resolved.
if scope_ids:
for sid in scope_ids:
db.add(AgentPermission(agent_id=agent_id, scope_type="user", scope_id=uuid.UUID(sid), access_level=access_level))
else:
# "仅自己"
db.add(AgentPermission(agent_id=agent_id, scope_type="user", scope_id=current_user.id, access_level="manage"))
# Check if we have per-user access levels
user_permissions = data.get('user_permissions', None)
if user_permissions and isinstance(user_permissions, dict):
# Per-user permissions: {user_id: access_level}
for sid in scope_ids:
user_access = user_permissions.get(str(sid), access_level)
# Validate: only allow 'use' or 'manage', default to 'use' for safety
if user_access not in ("use", "manage"):
user_access = "use"
db.add(AgentPermission(agent_id=agent_id, scope_type="user_group", scope_id=uuid.UUID(sid), access_level=user_access))
Comment thread
zonglinZhang marked this conversation as resolved.
Comment on lines +522 to +526
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate user_group IDs before casting to UUID

In update_agent_permissions, scope_ids is read from an untyped dict and each value is passed directly to uuid.UUID(...). If a client sends any malformed ID (for example, a typo in one selected user ID), this raises ValueError and returns a 500 instead of a 4xx validation error, turning a bad request into a server fault and breaking permission updates for the new user_group flow.

Useful? React with 👍 / 👎.

else:
# Legacy: all users share the same access level
for sid in scope_ids:
db.add(AgentPermission(agent_id=agent_id, scope_type="user_group", scope_id=uuid.UUID(sid), access_level=access_level))

await db.commit()
return {"status": "ok"}
Expand Down
6 changes: 4 additions & 2 deletions backend/app/api/wecom.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
from app.models.identity import IdentityProvider, SSOScanSession
from app.models.user import User
from app.services.activity_logger import log_activity
from app.services.auth_provider import auth_provider_registry
from app.services.auth_registry import auth_provider_registry
Comment thread
zonglinZhang marked this conversation as resolved.
from app.services.channel_session import find_or_create_channel_session
from app.services.channel_user_service import channel_user_service
from app.services.platform_service import platform_service
Expand Down Expand Up @@ -686,7 +686,9 @@ async def wecom_callback(

# 2. Extract user info and login/register via RegistrationService
try:
auth_provider = auth_provider_registry.get_provider(provider)
auth_provider = await auth_provider_registry.get_provider(db, "wecom", tenant_id)
if not auth_provider:
raise HTTPException(status_code=404, detail="WeCom provider not found in registry")

token_data = await auth_provider.exchange_code_for_token(code)
access_token_str = token_data.get("access_token")
Expand Down
6 changes: 5 additions & 1 deletion backend/app/core/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ async def check_agent_access(db: AsyncSession, user: User, agent_id: uuid.UUID)
Access is granted if:
1. User is platform admin → manage
2. User is the agent creator → manage
3. User has explicit permission (company/user scope) → from permission record
3. User has explicit permission (company/user/private scope) → from permission record
"""
result = await db.execute(select(Agent).where(Agent.id == agent_id))
agent = result.scalar_one_or_none()
Expand All @@ -47,6 +47,10 @@ async def check_agent_access(db: AsyncSession, user: User, agent_id: uuid.UUID)
if perm.scope_type == "company":
return agent, perm.access_level or "use"
if perm.scope_type == "user" and perm.scope_id == user.id:
# User scope: only the creator can access
return agent, perm.access_level or "manage"
if perm.scope_type == "user_group" and perm.scope_id == user.id:
# User group scope: specific users can access
return agent, perm.access_level or "use"

raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No access to this agent")
Expand Down
4 changes: 2 additions & 2 deletions backend/app/models/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,10 +137,10 @@ class AgentPermission(Base):
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
agent_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("agents.id"), nullable=False)
scope_type: Mapped[str] = mapped_column(
Enum("company", "department", "user", name="permission_scope_enum"),
Enum("company", "department", "user", "user_group", name="permission_scope_enum"),
Comment thread
zonglinZhang marked this conversation as resolved.
Comment thread
zonglinZhang marked this conversation as resolved.
nullable=False,
)
# scope_id: null for company, user_id for user scope
# scope_id: null for company, user_id for user/user_group scope
scope_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
# access_level: 'use' = task/chat/tool/skill/workspace only, 'manage' = full access
access_level: Mapped[str] = mapped_column(String(20), default="use", nullable=False)
Expand Down
3 changes: 2 additions & 1 deletion backend/app/schemas/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,9 +216,10 @@ class AgentCreate(BaseModel):
primary_model_id: uuid.UUID | None = None
fallback_model_id: uuid.UUID | None = None
# Permissions
permission_scope_type: str = "company" # company | user
permission_scope_type: str = "company" # company | user (only me) | user_group (specific users)
permission_scope_ids: list[uuid.UUID] = []
permission_access_level: str = "use" # use | manage
user_permissions: dict[str, str] | None = None # {user_id: access_level} for per-user permissions
# Target tenant (admin-only override; otherwise ignored)
tenant_id: uuid.UUID | None = None
# Template
Expand Down
15 changes: 12 additions & 3 deletions backend/app/services/agent_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,18 @@ class AgentManager:

def __init__(self):
try:
self.docker_client = docker.from_env()
except DockerException:
logger.warning("Docker not available — agent containers will not be managed")
# Set a timeout to avoid hanging if Docker daemon is unresponsive
import os
if os.getenv('SKIP_DOCKER', 'false').lower() == 'true':
logger.info("Docker skipped via SKIP_DOCKER environment variable")
self.docker_client = None
else:
self.docker_client = docker.from_env()
# Quick health check - ping Docker daemon
self.docker_client.ping()
logger.info("Docker connected successfully")
except Exception as e:
logger.warning(f"Docker not available ({e}) — agent containers will not be managed")
self.docker_client = None

def _agent_dir(self, agent_id: uuid.UUID) -> Path:
Expand Down
31 changes: 27 additions & 4 deletions frontend/src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -496,14 +496,26 @@
"description": "Control who can see and interact with this agent. Only the creator or admin can change this.",
"companyWide": "Company-wide",
"companyWideDesc": "All users in the organization can use this agent",
"onlyMe": "Only Me",
"specificUsers": "Specific Users",
"specificUsersDesc": "Only selected users can use this agent",
"onlyMe": "Private",
"onlyMeDesc": "Only the creator can use this agent",
"selectUsers": "Select Users",
"selectedCount": "{{count}} users selected",
"defaultAccess": "Default Access Level",
"useAccess": "Use",
"useAccessDesc": "Task, Chat, Tools, Skills, Workspace",
"manageAccess": "Manage",
"manageAccessDesc": "Full access including Settings, Mind, Relationships"
"manageAccessDesc": "Full access including Settings, Mind, Relationships",
"selectAtLeastOneUser": "Please select at least one user before choosing \"Specific Users\" permission",
"userGroupAccessHint": "All selected users will have this access level",
"canUse": "Can Use",
"canManage": "Can Manage",
"creator": "(Creator)",
"cannotChangeCreator": "Cannot change creator permission"
},
"accessDenied": "Access Denied",
"accessDeniedDesc": "You do not have permission to view or modify this agent's settings. Please contact the agent creator or administrator.",
"timezone": {
"label": "Timezone",
"description": "Set the timezone for scheduled tasks and time-based triggers",
Expand Down Expand Up @@ -811,10 +823,19 @@
"title": "Permissions",
"companyWide": "Company-wide",
"companyWideDesc": "Everyone can use this digital employee",
"specificUsers": "Specific Users",
"specificUsersDesc": "Only selected users can use this agent",
"department": "Department",
"departmentDesc": "Only selected department members can use",
"selfOnly": "Self Only",
"selfOnlyDesc": "Only the creator can use"
"selfOnly": "Private",
"selfOnlyDesc": "Only the creator can use",
"selectUsers": "Select Users",
"selectedCount": "{{count}} users selected",
"accessLevel": "Default Access Level",
"useLevel": "Use",
"useDesc": "Can use Task, Chat, Tools, Skills, Workspace",
"manageLevel": "Manage",
"manageDesc": "Full access including Settings, Mind, Relationships"
},
"step5": {
"title": "Feishu Bot Configuration (Optional)",
Expand Down Expand Up @@ -1463,6 +1484,8 @@
"confirm": "Confirm",
"delete": "Delete",
"search": "Search",
"searchPlaceholder": "Search by name or email...",
"noSearchResults": "No users found matching your search",
"loading": "Loading...",
"noData": "No data",
"error": "Error",
Expand Down
Loading