Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -571,6 +571,7 @@ t('actions.save') // Ambiguous - which namespace?
- Backend encrypts Git tokens and API keys (AES-256-CBC)
- OIDC support for enterprise SSO
- Role-based access control for admin operations
- Entity-based permission extension for internal org integration: [`docs/en/developer-guide/entity-permission-extension.md`](docs/en/developer-guide/entity-permission-extension.md)

---

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
# SPDX-FileCopyrightText: 2025 Weibo, Inc.
#
# SPDX-License-Identifier: Apache-2.0

"""extend_resource_members_for_entity

Revision ID: d1e2f3a4b5c6
Revises: c3d4e5f6a708
Create Date: 2026-05-06

Add entity_type, entity_id columns to resource_members table.
Change unique constraint from (resource_type, resource_id, user_id)
to (resource_type, resource_id, entity_type, entity_id).
Keep user_id column for backward compatibility (nullable, auto-synced from entity_id
for entity_type='user' via SQLAlchemy events).
entity_id is backfilled from user_id then set to NOT NULL.
"""

import sqlalchemy as sa

from alembic import op

revision = "d1e2f3a4b5c6"
down_revision = "c3d4e5f6a708"
branch_labels = None
depends_on = None


def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = [col["name"] for col in inspector.get_columns("resource_members")]

# Step 1: Add entity_type column
if "entity_type" not in columns:
op.add_column(
"resource_members",
sa.Column(
"entity_type",
sa.String(20),
nullable=False,
server_default="user",
comment="Entity type: user, namespace",
),
)

# Step 2: Add entity_id column
if "entity_id" not in columns:
op.add_column(
"resource_members",
sa.Column(
"entity_id",
sa.String(100),
nullable=True,
comment="Entity identifier: user_id for 'user', external ID for others",
),
)

# Step 3: Backfill existing rows - set entity_id = CAST(user_id AS CHAR), entity_type = 'user'
dialect = conn.dialect.name
if dialect == "mysql":
op.execute(
"""
UPDATE resource_members
SET entity_id = CAST(user_id AS CHAR), entity_type = 'user'
WHERE entity_id IS NULL
"""
)
elif dialect == "sqlite":
op.execute(
"""
UPDATE resource_members
SET entity_id = CAST(user_id AS TEXT), entity_type = 'user'
WHERE entity_id IS NULL
"""
)

# Step 3.5: Make entity_id NOT NULL after backfill
if dialect == "mysql":
op.alter_column(
"resource_members",
"entity_id",
existing_type=sa.String(100),
nullable=False,
)
elif dialect == "sqlite":
with op.batch_alter_table("resource_members") as batch_op:
batch_op.alter_column(
"entity_id",
existing_type=sa.String(100),
nullable=False,
)

# Step 4: Make user_id nullable (kept for backward compatibility)
# user_id is auto-synced from entity_id for entity_type='user' via SQLAlchemy events
if dialect == "mysql":
op.execute(
"ALTER TABLE resource_members MODIFY user_id INT NULL COMMENT 'Member user ID (kept for backward compatibility, use entity_type+entity_id for new code)'"
)
elif dialect == "sqlite":
with op.batch_alter_table("resource_members") as batch_op:
batch_op.alter_column(
"user_id",
existing_type=sa.Integer(),
nullable=True,
)

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
# Step 6: Add entity_display_name for entity-type member snapshots
if "entity_display_name" not in columns:
op.add_column(
"resource_members",
sa.Column(
"entity_display_name",
sa.String(100),
nullable=True,
comment="Display name snapshot for entity-type members",
),
)

# Step 5: Drop old unique constraint and create new one
if dialect == "mysql":
# Dynamically find the actual unique constraint/index name from MySQL
old_uc_name_result = conn.execute(
sa.text(
"SELECT CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS "
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'resource_members' "
"AND CONSTRAINT_TYPE = 'UNIQUE'"
)
).fetchone()
old_uc_name = (
old_uc_name_result[0] if old_uc_name_result else "uq_resource_members"
)

op.drop_constraint(old_uc_name, "resource_members", type_="unique")
op.create_unique_constraint(
"uq_resource_members_entity",
"resource_members",
["resource_type", "resource_id", "entity_type", "entity_id"],
)
else:
# SQLite: use batch operations
with op.batch_alter_table("resource_members") as batch_op:
batch_op.drop_constraint("uq_resource_members", type_="unique")
batch_op.create_unique_constraint(
"uq_resource_members_entity",
["resource_type", "resource_id", "entity_type", "entity_id"],
)

# Step 7: Add index for entity-type queries
op.create_index(
"idx_resource_members_entity",
"resource_members",
["entity_type", "entity_id", "resource_type", "status"],
)


def downgrade() -> None:
conn = op.get_bind()
dialect = conn.dialect.name

# Drop entity index
op.drop_index("idx_resource_members_entity", table_name="resource_members")

# Drop new unique constraint, restore old one
if dialect == "mysql":
# Dynamically find the current unique constraint name
new_uc_name_result = conn.execute(
sa.text(
"SELECT CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS "
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'resource_members' "
"AND CONSTRAINT_TYPE = 'UNIQUE'"
)
).fetchone()
new_uc_name = (
new_uc_name_result[0]
if new_uc_name_result
else "uq_resource_members_entity"
)

op.drop_constraint(new_uc_name, "resource_members", type_="unique")
op.create_unique_constraint(
"uq_resource_members",
"resource_members",
["resource_type", "resource_id", "user_id"],
)
else:
with op.batch_alter_table("resource_members") as batch_op:
batch_op.drop_constraint("uq_resource_members_entity", type_="unique")
batch_op.create_unique_constraint(
"uq_resource_members",
["resource_type", "resource_id", "user_id"],
)

# Drop added columns (check existence to tolerate partial rollback states)
inspector = sa.inspect(conn)
columns = [col["name"] for col in inspector.get_columns("resource_members")]
if "entity_display_name" in columns:
op.drop_column("resource_members", "entity_display_name")
if "entity_id" in columns:
op.drop_column("resource_members", "entity_id")
if "entity_type" in columns:
op.drop_column("resource_members", "entity_type")
6 changes: 4 additions & 2 deletions backend/app/api/endpoints/adapter/attachments.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,8 @@ def _check_task_access(db: Session, task_id: int, user_id: int) -> bool:
.filter(
ResourceMember.resource_type == ResourceType.TASK,
ResourceMember.resource_id == task_id,
ResourceMember.user_id == user_id,
ResourceMember.entity_type == "user",
ResourceMember.entity_id == str(user_id),
ResourceMember.status == MemberStatus.APPROVED,
)
.first()
Expand Down Expand Up @@ -821,7 +822,8 @@ async def get_all_task_attachments(
.filter(
ResourceMember.resource_type == ResourceType.TASK,
ResourceMember.resource_id == task_id,
ResourceMember.user_id == current_user.id,
ResourceMember.entity_type == "user",
ResourceMember.entity_id == str(current_user.id),
Comment thread
cocowh marked this conversation as resolved.
Resolved
ResourceMember.status == MemberStatus.APPROVED,
)
.first()
Expand Down
9 changes: 6 additions & 3 deletions backend/app/api/endpoints/adapter/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,8 @@ async def correct_response(
.filter(
ResourceMember.resource_type == ResourceType.TASK,
ResourceMember.resource_id == request.task_id,
ResourceMember.user_id == current_user.id,
ResourceMember.entity_type == "user",
ResourceMember.entity_id == str(current_user.id),
ResourceMember.status == MemberStatus.APPROVED,
)
.first()
Expand Down Expand Up @@ -428,7 +429,8 @@ async def delete_correction(
.filter(
ResourceMember.resource_type == ResourceType.TASK,
ResourceMember.resource_id == subtask.task_id,
ResourceMember.user_id == current_user.id,
ResourceMember.entity_type == "user",
ResourceMember.entity_id == str(current_user.id),
ResourceMember.status == MemberStatus.APPROVED,
)
.first()
Expand Down Expand Up @@ -502,7 +504,8 @@ async def apply_correction(
.filter(
ResourceMember.resource_type == ResourceType.TASK,
ResourceMember.resource_id == subtask.task_id,
ResourceMember.user_id == current_user.id,
ResourceMember.entity_type == "user",
ResourceMember.entity_id == str(current_user.id),
ResourceMember.status == MemberStatus.APPROVED,
)
.first()
Expand Down
31 changes: 31 additions & 0 deletions backend/app/api/endpoints/groups.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,37 @@ def list_groups(
return GroupListResponse(total=total, items=groups)


@router.get("/search", response_model=GroupListResponse)
def search_groups_endpoint(
q: str = Query(
"",
min_length=0,
max_length=100,
description="Search query for group name or display_name",
),
page: int = Query(1, ge=1, description="Page number"),
limit: int = Query(20, ge=1, le=100, description="Items per page"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Search groups by name or display_name.
Only returns groups with level='group' (filters out organization-level groups).
Results are limited to groups where the current user is a member.
Returns paginated results.
"""
skip = (page - 1) * limit
groups, total = group_service.search_groups(
db=db,
q=q,
skip=skip,
limit=limit,
user_id=current_user.id,
user_role=current_user.role,
)
return GroupListResponse(total=total, items=groups)


@router.post("", response_model=GroupResponse, status_code=status.HTTP_201_CREATED)
def create_group_endpoint(
group_create: GroupCreate,
Expand Down
57 changes: 57 additions & 0 deletions backend/app/api/endpoints/knowledge.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
DocumentContentUpdate,
DocumentDetailResponse,
DocumentMoveRequest,
InitialMemberCreate,
KnowledgeBaseCreate,
KnowledgeBaseListResponse,
KnowledgeBaseMigrateRequest,
Expand Down Expand Up @@ -298,6 +299,56 @@ def get_organization_namespace(
}


def _add_initial_kb_members(
db: Session,
kb_id: int,
current_user: User,
members: list[InitialMemberCreate],
) -> None:
"""Add initial members to a knowledge base after creation."""
from app.services.share import knowledge_share_service

members_data: list[tuple[int, BaseRole, str | None, str | None, str | None]] = []
for member in members:
target_user_id = 0
entity_type = member.entity_type if member.entity_type else "user"
entity_id = member.entity_id
if entity_type == "user":
try:
target_user_id = int(entity_id)
except (ValueError, TypeError):
raise HTTPException(
status_code=400,
detail=f"Invalid user ID: {entity_id}",
)
entity_id = None
members_data.append(
(
target_user_id,
member.role,
entity_type,
entity_id,
member.entity_display_name,
)
)

result = knowledge_share_service.batch_add_members(
db=db,
resource_id=kb_id,
current_user_id=current_user.id,
members_data=members_data,
)

if result.failed:
details = ", ".join(
f"{f.entity_id or f.user_id}: {f.error}" for f in result.failed
)
raise HTTPException(
status_code=400,
detail=f"Failed to add some members: {details}",
)

Comment thread
cocowh marked this conversation as resolved.
Resolved

@router.post(
"",
response_model=KnowledgeBaseResponse,
Expand All @@ -314,6 +365,7 @@ def create_knowledge_base(

- **namespace=default**: Personal knowledge base
- **namespace=<group_name>**: Team knowledge base (requires Maintainer+ permission)
- **members**: Optional initial members to add after creation
"""
try:
# Use Orchestrator for unified business logic (REST API and MCP tools share the same logic)
Expand All @@ -330,6 +382,11 @@ def create_knowledge_base(
),
summary_model_ref=data.summary_model_ref,
)

# Add initial members if provided
if data.members:
_add_initial_kb_members(db, result.id, current_user, data.members)

add_span_event(
"knowledge.base.created",
{
Expand Down
Loading
Loading