feat(go-api): implement tenant member management (issue #15294) (#15295)

## Summary

Ports the Python `tenant_api` team/member management endpoints to Go,
adding 4 endpoints under `/api/v1/tenants/:tenant_id/`:

- `GET /tenants/:tenant_id/users` — list non-owner members with user
details (owner only)
- `POST /tenants/:tenant_id/users` — invite a user by email; creates
invite-role join record (owner only)
- `DELETE /tenants/:tenant_id/users` — remove a member by `user_id`;
owner can remove anyone, members can remove themselves
- `PATCH /tenants/:tenant_id` — accept a pending invitation,
transitioning role `invite → normal`

Closes #15294
This commit is contained in:
web-dev0521
2026-05-28 20:13:09 -06:00
committed by GitHub
parent 834236a3ec
commit 550bdf215c
5 changed files with 454 additions and 0 deletions

View File

@@ -17,6 +17,8 @@
package dao
import (
"fmt"
"ragflow/internal/entity"
)
@@ -114,6 +116,38 @@ type TenantInfoByUserID struct {
UpdateDate string `json:"update_date"`
}
// TenantMemberItem holds user details for a tenant member listing.
type TenantMemberItem struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Role string `json:"role"`
Status string `json:"status"`
Nickname string `json:"nickname"`
Email string `json:"email"`
Avatar string `json:"avatar"`
IsAuthenticated bool `json:"is_authenticated"`
IsActive string `json:"is_active"`
IsAnonymous bool `json:"is_anonymous"`
IsSuperuser bool `json:"is_superuser"`
UpdateDate string `json:"update_date"`
}
// GetMembersByTenantID returns all non-owner members of a tenant with user details.
// update_date is formatted as "2006-01-02T15:04:05" (no timezone) to match the Python API.
func (dao *UserTenantDAO) GetMembersByTenantID(tenantID string) ([]*TenantMemberItem, error) {
var results []*TenantMemberItem
err := DB.Table("user_tenant").
Select("user_tenant.id, user_tenant.user_id, user_tenant.role, user_tenant.status, "+
"user.nickname, user.email, user.avatar, user.is_authenticated, "+
"user.status AS is_active, user.is_anonymous, user.is_superuser, "+
"DATE_FORMAT(user.update_date, '%Y-%m-%dT%H:%i:%s') AS update_date").
Joins("JOIN user ON user_tenant.user_id = user.id").
Where("user_tenant.tenant_id = ? AND user_tenant.status = ? AND user_tenant.role != ?",
tenantID, "1", "owner").
Scan(&results).Error
return results, err
}
// GetTenantsByUserID get tenants by user ID with user details
func (dao *UserTenantDAO) GetTenantsByUserID(userID string) ([]*TenantInfoByUserID, error) {
var results []*TenantInfoByUserID
@@ -143,3 +177,25 @@ func (dao *UserTenantDAO) GetByUserIDAll(userID string) ([]*entity.UserTenant, e
err := DB.Where("user_id = ?", userID).Find(&relations).Error
return relations, err
}
// DeleteByUserAndTenant hard-deletes the join record for a specific user+tenant pair.
func (dao *UserTenantDAO) DeleteByUserAndTenant(userID, tenantID string) error {
return DB.Unscoped().
Where("user_id = ? AND tenant_id = ?", userID, tenantID).
Delete(&entity.UserTenant{}).Error
}
// UpdateRoleByUserAndTenant updates the role for a specific user+tenant pair.
// Returns an error if no matching row was found.
func (dao *UserTenantDAO) UpdateRoleByUserAndTenant(userID, tenantID, role string) error {
result := DB.Model(&entity.UserTenant{}).
Where("user_id = ? AND tenant_id = ? AND status = ?", userID, tenantID, "1").
Update("role", role)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("no active membership found for user %s in tenant %s", userID, tenantID)
}
return nil
}

View File

@@ -542,3 +542,129 @@ func (h *TenantHandler) InsertMetadataFromFile(c *gin.Context) {
"message": "success",
})
}
// ListTenantMembers lists all non-owner members of a tenant.
// @Summary List tenant members
// @Tags tenants
// @Produce json
// @Param tenant_id path string true "Tenant ID"
// @Router /api/v1/tenants/{tenant_id}/users [get]
func (h *TenantHandler) ListTenantMembers(c *gin.Context) {
user, errorCode, errorMessage := GetUser(c)
if errorCode != common.CodeSuccess {
jsonError(c, errorCode, errorMessage)
return
}
tenantID := c.Param("tenant_id")
if tenantID == "" {
c.JSON(http.StatusBadRequest, gin.H{"code": common.CodeBadRequest, "data": nil, "message": "tenant_id is required"})
return
}
members, code, err := h.tenantService.ListMembers(user.ID, tenantID)
if err != nil {
c.JSON(http.StatusOK, gin.H{"code": code, "data": nil, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"code": common.CodeSuccess, "data": members, "message": "success"})
}
// AddTenantMember invites a user (by email) to the tenant.
// @Summary Invite a user to a tenant
// @Tags tenants
// @Accept json
// @Produce json
// @Param tenant_id path string true "Tenant ID"
// @Param request body service.AddMemberRequest true "Invite request"
// @Router /api/v1/tenants/{tenant_id}/users [post]
func (h *TenantHandler) AddTenantMember(c *gin.Context) {
user, errorCode, errorMessage := GetUser(c)
if errorCode != common.CodeSuccess {
jsonError(c, errorCode, errorMessage)
return
}
tenantID := c.Param("tenant_id")
if tenantID == "" {
c.JSON(http.StatusBadRequest, gin.H{"code": common.CodeBadRequest, "data": nil, "message": "tenant_id is required"})
return
}
var req service.AddMemberRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": common.CodeBadRequest, "data": nil, "message": "invalid request body: " + err.Error()})
return
}
resp, code, err := h.tenantService.AddMember(user.ID, tenantID, &req)
if err != nil {
c.JSON(http.StatusOK, gin.H{"code": code, "data": nil, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"code": common.CodeSuccess, "data": resp, "message": "success"})
}
// RemoveTenantMember removes a user from the tenant.
// @Summary Remove a user from a tenant
// @Tags tenants
// @Accept json
// @Produce json
// @Param tenant_id path string true "Tenant ID"
// @Param request body object true "Remove member request" SchemaExample({"user_id":"string"})
// @Router /api/v1/tenants/{tenant_id}/users [delete]
func (h *TenantHandler) RemoveTenantMember(c *gin.Context) {
user, errorCode, errorMessage := GetUser(c)
if errorCode != common.CodeSuccess {
jsonError(c, errorCode, errorMessage)
return
}
tenantID := c.Param("tenant_id")
if tenantID == "" {
c.JSON(http.StatusBadRequest, gin.H{"code": common.CodeBadRequest, "data": nil, "message": "tenant_id is required"})
return
}
var body struct {
UserID string `json:"user_id"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.UserID == "" {
c.JSON(http.StatusBadRequest, gin.H{"code": common.CodeBadRequest, "data": nil, "message": "user_id is required"})
return
}
code, err := h.tenantService.RemoveMember(user.ID, tenantID, body.UserID)
if err != nil {
c.JSON(http.StatusOK, gin.H{"code": code, "data": nil, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"code": common.CodeSuccess, "data": true, "message": "success"})
}
// AcceptTenantInvite accepts a pending team invitation, transitioning role invite → normal.
// @Summary Accept tenant invitation
// @Tags tenants
// @Produce json
// @Param tenant_id path string true "Tenant ID"
// @Router /api/v1/tenants/{tenant_id} [patch]
func (h *TenantHandler) AcceptTenantInvite(c *gin.Context) {
user, errorCode, errorMessage := GetUser(c)
if errorCode != common.CodeSuccess {
jsonError(c, errorCode, errorMessage)
return
}
tenantID := c.Param("tenant_id")
if tenantID == "" {
c.JSON(http.StatusBadRequest, gin.H{"code": common.CodeBadRequest, "data": nil, "message": "tenant_id is required"})
return
}
code, err := h.tenantService.AcceptInvite(user.ID, tenantID)
if err != nil {
c.JSON(http.StatusOK, gin.H{"code": code, "data": nil, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"code": common.CodeSuccess, "data": true, "message": "success"})
}

View File

@@ -160,6 +160,10 @@ func (r *Router) Setup(engine *gin.Engine) {
tenants := v1.Group("/tenants")
{
tenants.GET("", r.tenantHandler.TenantList)
tenants.PATCH("/:tenant_id", r.tenantHandler.AcceptTenantInvite)
tenants.GET("/:tenant_id/users", r.tenantHandler.ListTenantMembers)
tenants.POST("/:tenant_id/users", r.tenantHandler.AddTenantMember)
tenants.DELETE("/:tenant_id/users", r.tenantHandler.RemoveTenantMember)
}
v1.GET("/tenant/list", r.tenantHandler.TenantList)

View File

@@ -30,6 +30,7 @@ import (
type TenantService struct {
tenantDAO *dao.TenantDAO
userTenantDAO *dao.UserTenantDAO
userDAO *dao.UserDAO
modelProviderDAO *dao.TenantModelProviderDAO
modelInstanceDAO *dao.TenantModelInstanceDAO
modelDAO *dao.TenantModelDAO
@@ -44,6 +45,7 @@ func NewTenantService() *TenantService {
return &TenantService{
tenantDAO: dao.NewTenantDAO(),
userTenantDAO: dao.NewUserTenantDAO(),
userDAO: dao.NewUserDAO(),
modelProviderDAO: dao.NewTenantModelProviderDAO(),
modelInstanceDAO: dao.NewTenantModelInstanceDAO(),
modelDAO: dao.NewTenantModelDAO(),
@@ -654,3 +656,157 @@ func (s *TenantService) SetTenantDefaultModels(userID, modelProvider, modelInsta
return nil
}
// Tenant member role constants.
const (
TenantRoleOwner = "owner"
TenantRoleNormal = "normal"
TenantRoleInvite = "invite"
TenantRoleAdmin = "admin"
)
// TenantMemberResponse is one entry in the member list response.
type TenantMemberResponse struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Role string `json:"role"`
Status string `json:"status"`
Nickname string `json:"nickname"`
Email string `json:"email"`
Avatar string `json:"avatar"`
IsAuthenticated bool `json:"is_authenticated"`
IsActive string `json:"is_active"`
IsAnonymous bool `json:"is_anonymous"`
IsSuperuser bool `json:"is_superuser"`
UpdateDate string `json:"update_date"`
DeltaSeconds float64 `json:"delta_seconds"`
}
// ListMembers returns all non-owner members of tenantID.
// Only the tenant owner (userID == tenantID) may call this.
func (s *TenantService) ListMembers(userID, tenantID string) ([]*TenantMemberResponse, common.ErrorCode, error) {
if userID != tenantID {
return nil, common.CodeAuthenticationError, fmt.Errorf("no authorization")
}
rows, err := s.userTenantDAO.GetMembersByTenantID(tenantID)
if err != nil {
return nil, common.CodeServerError, err
}
result := make([]*TenantMemberResponse, 0, len(rows))
for _, r := range rows {
delta, _ := common.DeltaSeconds(r.UpdateDate)
result = append(result, &TenantMemberResponse{
ID: r.ID,
UserID: r.UserID,
Role: r.Role,
Status: r.Status,
Nickname: r.Nickname,
Email: r.Email,
Avatar: r.Avatar,
IsAuthenticated: r.IsAuthenticated,
IsActive: r.IsActive,
IsAnonymous: r.IsAnonymous,
IsSuperuser: r.IsSuperuser,
UpdateDate: r.UpdateDate,
DeltaSeconds: delta,
})
}
return result, common.CodeSuccess, nil
}
// AddMemberRequest holds the invite payload.
type AddMemberRequest struct {
Email string `json:"email"`
}
// AddMemberResponse holds the new member's public data.
type AddMemberResponse struct {
ID string `json:"id"`
Avatar string `json:"avatar"`
Email string `json:"email"`
Nickname string `json:"nickname"`
}
// AddMember invites a user (by email) to the tenant.
// Only the tenant owner (userID == tenantID) may call this.
func (s *TenantService) AddMember(userID, tenantID string, req *AddMemberRequest) (*AddMemberResponse, common.ErrorCode, error) {
if userID != tenantID {
return nil, common.CodeAuthenticationError, fmt.Errorf("no authorization")
}
if req.Email == "" {
return nil, common.CodeArgumentError, fmt.Errorf("email is required")
}
invitee, err := s.userDAO.GetByEmail(req.Email)
if err != nil {
return nil, common.CodeDataError, fmt.Errorf("user not found")
}
// Reject if already a member or has a pending invitation.
existing, _ := s.userTenantDAO.FilterByUserIDAndTenantID(invitee.ID, tenantID)
if existing != nil {
switch existing.Role {
case TenantRoleOwner:
return nil, common.CodeDataError, fmt.Errorf("user is already the tenant owner")
case TenantRoleNormal, TenantRoleAdmin:
return nil, common.CodeDataError, fmt.Errorf("user is already a member")
case TenantRoleInvite:
return nil, common.CodeDataError, fmt.Errorf("user already has a pending invitation")
}
}
status := "1"
ut := &entity.UserTenant{
ID: common.GenerateUUID(),
UserID: invitee.ID,
TenantID: tenantID,
Role: TenantRoleInvite,
InvitedBy: userID,
Status: &status,
}
if err := s.userTenantDAO.Create(ut); err != nil {
return nil, common.CodeServerError, fmt.Errorf("failed to create invitation: %w", err)
}
avatar := ""
if invitee.Avatar != nil {
avatar = *invitee.Avatar
}
return &AddMemberResponse{
ID: invitee.ID,
Avatar: avatar,
Email: invitee.Email,
Nickname: invitee.Nickname,
}, common.CodeSuccess, nil
}
// RemoveMember removes a user from the tenant.
// Either the owner (userID == tenantID) or the member themselves (userID == targetUserID) may call this.
// The tenant owner (targetUserID == tenantID) cannot be removed.
func (s *TenantService) RemoveMember(userID, tenantID, targetUserID string) (common.ErrorCode, error) {
if userID != tenantID && userID != targetUserID {
return common.CodeAuthenticationError, fmt.Errorf("no authorization")
}
if targetUserID == tenantID {
return common.CodeArgumentError, fmt.Errorf("cannot remove the tenant owner")
}
if err := s.userTenantDAO.DeleteByUserAndTenant(targetUserID, tenantID); err != nil {
return common.CodeServerError, fmt.Errorf("failed to remove member: %w", err)
}
return common.CodeSuccess, nil
}
// AcceptInvite transitions the calling user's role from "invite" → "normal" for the given tenant.
func (s *TenantService) AcceptInvite(userID, tenantID string) (common.ErrorCode, error) {
existing, err := s.userTenantDAO.FilterByUserIDAndTenantID(userID, tenantID)
if err != nil || existing == nil {
return common.CodeDataError, fmt.Errorf("no pending invitation found")
}
if existing.Role != TenantRoleInvite {
return common.CodeArgumentError, fmt.Errorf("no pending invitation to accept")
}
if err := s.userTenantDAO.UpdateRoleByUserAndTenant(userID, tenantID, TenantRoleNormal); err != nil {
return common.CodeServerError, fmt.Errorf("failed to accept invitation: %w", err)
}
return common.CodeSuccess, nil
}

View File

@@ -0,0 +1,112 @@
//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
package service
import (
"testing"
"ragflow/internal/common"
)
// TestListMembersAuthCheck verifies that a non-owner (userID != tenantID) gets
// CodeAuthenticationError without hitting the database.
func TestListMembersAuthCheck(t *testing.T) {
s := &TenantService{}
_, code, err := s.ListMembers("user-abc", "tenant-xyz")
if err == nil {
t.Fatal("expected error for non-owner, got nil")
}
if code != common.CodeAuthenticationError {
t.Errorf("expected CodeAuthenticationError, got %v", code)
}
}
// TestAddMemberAuthCheck verifies that a non-owner gets CodeAuthenticationError.
func TestAddMemberAuthCheck(t *testing.T) {
s := &TenantService{}
_, code, err := s.AddMember("user-abc", "tenant-xyz", &AddMemberRequest{Email: "a@b.com"})
if err == nil {
t.Fatal("expected error for non-owner, got nil")
}
if code != common.CodeAuthenticationError {
t.Errorf("expected CodeAuthenticationError, got %v", code)
}
}
// TestAddMemberEmailRequired verifies the email validation runs after the auth check.
func TestAddMemberEmailRequired(t *testing.T) {
// When userID == tenantID (owner) but no email, expect CodeArgumentError.
s := &TenantService{}
_, code, err := s.AddMember("owner-id", "owner-id", &AddMemberRequest{Email: ""})
if err == nil {
t.Fatal("expected error for empty email, got nil")
}
if code != common.CodeArgumentError {
t.Errorf("expected CodeArgumentError, got %v", code)
}
}
// TestRemoveMemberAuthCheck verifies that an unrelated user gets CodeAuthenticationError.
func TestRemoveMemberAuthCheck(t *testing.T) {
s := &TenantService{}
code, err := s.RemoveMember("user-abc", "tenant-xyz", "user-def")
if err == nil {
t.Fatal("expected error, got nil")
}
if code != common.CodeAuthenticationError {
t.Errorf("expected CodeAuthenticationError, got %v", code)
}
}
// TestRemoveMemberSelfAllowed verifies that a user removing themselves passes the auth check.
// (It will fail at the DB layer in unit tests, but the auth check must pass first.)
func TestRemoveMemberSelfAllowed(t *testing.T) {
s := &TenantService{}
// userID == targetUserID: auth check should pass, expect DB error (nil userTenantDAO).
code, err := s.RemoveMember("user-abc", "tenant-xyz", "user-abc")
if code == common.CodeAuthenticationError {
t.Errorf("self-removal should pass auth check, got CodeAuthenticationError: %v", err)
}
}
// TestAcceptInviteAuthCheck verifies that AcceptInvite fails when no membership exists (nil DAO).
func TestAcceptInviteAuthCheck(t *testing.T) {
s := &TenantService{}
// nil userTenantDAO: FilterByUserIDAndTenantID will panic/err, so we expect CodeDataError.
code, err := s.AcceptInvite("user-abc", "tenant-xyz")
if err == nil {
t.Fatal("expected error when no membership exists, got nil")
}
if code != common.CodeDataError {
t.Errorf("expected CodeDataError, got %v", code)
}
}
// TestTenantRoleConstants verifies the role string values match the Python enums.
func TestTenantRoleConstants(t *testing.T) {
cases := map[string]string{
TenantRoleOwner: "owner",
TenantRoleNormal: "normal",
TenantRoleInvite: "invite",
TenantRoleAdmin: "admin",
}
for got, want := range cases {
if got != want {
t.Errorf("role constant = %q, want %q", got, want)
}
}
}