diff --git a/monkeyai/backend/api/agent.yaml b/monkeyai/backend/api/agent.yaml index 9ac264bac..02492dd17 100644 --- a/monkeyai/backend/api/agent.yaml +++ b/monkeyai/backend/api/agent.yaml @@ -946,6 +946,28 @@ paths: description: 调用密钥无效 '403': description: 请求来源不允许 + /api/v1/groups: + get: + summary: 搜索可分享的真实分组 + description: 按名称不区分大小写进行字面子串匹配,仅返回未删除分组;不包含虚拟根组及成员信息。分享给父组可覆盖后代组成员。 + parameters: + - in: query + name: q + required: true + schema: {type: string, minLength: 1, maxLength: 200} + description: 去除首尾空白后长度为 1—200 字节。 + - in: query + name: limit + schema: {type: integer, minimum: 1, maximum: 100, default: 20} + responses: + '200': + description: 匹配的分组。 + content: + application/json: + schema: {$ref: '#/components/schemas/SearchGroupsResponse'} + '400': {$ref: '#/components/responses/BadRequest'} + '401': {$ref: '#/components/responses/Unauthorized'} + '500': {$ref: '#/components/responses/InternalError'} /api/v1/users: get: summary: 按用户名或邮箱查找分享接收用户 @@ -1109,9 +1131,8 @@ paths: $ref: '#/components/responses/ResourceError500' /api/v1/resources/shares: post: - summary: 批量分享自己的资源给指定用户 - description: 支持 model、rule、skill、connector、expert;个人规则共享后接收者可选择使用,但不能强制应用。个人 Connector 的接收方使用自己的独立凭证,个人专家的依赖仍需接收方获得使用授权。分享为追加的只读使用授权,不覆盖其他接收用户;重复授权不会产生重复记录。每批资源和用户各 - 1—100 项,任一资源非本人所有或任一接收用户无效时整批回滚。禁止分享给自己,不允许接收方转分享。 + summary: 批量分享自己的资源给指定用户或分组 + description: 支持 model、rule、skill、connector、expert;个人规则共享后接收者可选择使用,但不能强制应用。个人 Connector 的接收方使用自己的独立凭证,个人专家的依赖仍需接收方获得使用授权。分享为追加的只读使用授权,不覆盖其他授权;重复授权不会产生重复记录。每批资源 1—100 项,用户与分组 ID 合计 1—100 项;任一资源非本人所有、任一接收用户无效或分组不存在时整批回滚。禁止直接分享给自己或虚拟根组,不允许接收方转分享。组授权覆盖其后代组成员并随成员关系变化即时生效。 requestBody: required: true content: @@ -1130,8 +1151,8 @@ paths: '500': $ref: '#/components/responses/ResourceError500' delete: - summary: 批量撤销指定用户的资源分享 - description: 仅所有者可以撤销。只移除本批指定资源与用户间的授权,不存在的授权视为成功;允许撤销已停用或已删除用户的授权。任一资源无权操作则整批回滚。 + summary: 批量撤销指定用户或分组的资源分享 + description: 仅所有者可以撤销。只移除本批指定资源与用户或分组间的直接授权,不影响其他用户/分组的授权;不存在的授权视为成功,允许撤销已停用或已删除的接收对象。任一资源无权操作则整批回滚。 requestBody: required: true content: @@ -3387,6 +3408,23 @@ components: email: type: string format: email + GroupSummary: + type: object + required: [id, name, parent_id] + properties: + id: {type: string, format: uuid} + name: {type: string} + parent_id: + type: string + format: uuid + description: 顶层分组的父 ID 为虚拟根组 ID;虚拟根组自身不可分享。 + SearchGroupsResponse: + type: object + required: [groups] + properties: + groups: + type: array + items: {$ref: '#/components/schemas/GroupSummary'} SearchUsersResponse: type: object required: @@ -3552,6 +3590,11 @@ components: type: number exclusiveMinimum: 0 description: 服务端管理,用户不可修改。 + shared_groups: + type: array + readOnly: true + items: {$ref: '#/components/schemas/GroupSummary'} + description: 当前所有者直接分享的分组,未分享时为空数组。 authorization: type: object required: @@ -3574,9 +3617,9 @@ components: format: date-time ShareResourcesInput: type: object + description: user_ids 与 group_ids 至少填写一项,原始数组合计最多 100 项;重复项去重。虚拟根组不可作为分享对象。 required: - resources - - user_ids properties: resources: type: array @@ -3600,10 +3643,14 @@ components: id: *id006 user_ids: type: array - minItems: 1 maxItems: 100 items: *id006 description: 接收用户 ID,重复项自动去重。 + group_ids: + type: array + maxItems: 100 + items: *id006 + description: 接收真实分组 ID,重复项自动去重;不展开成员。 ErrorResponse: type: object required: [error] @@ -4011,6 +4058,11 @@ components: creator: $ref: '#/components/schemas/UserSummary' description: 他人创建的用户模型返回创建者;自己的模型和系统模型不返回。 + shared_groups: + type: array + readOnly: true + items: {$ref: '#/components/schemas/GroupSummary'} + description: 仅资源所有者可见的直接分享分组,未分享时为空数组;不展开成员。 shared_users: type: array items: @@ -4301,6 +4353,11 @@ components: AgentRule: type: object properties: + shared_groups: + type: array + readOnly: true + items: {$ref: '#/components/schemas/GroupSummary'} + description: 仅资源所有者可见的直接分享分组,未分享时为空数组;不展开成员。 shared_users: type: array readOnly: true @@ -4342,6 +4399,11 @@ components: AgentSkill: type: object properties: + shared_groups: + type: array + readOnly: true + items: {$ref: '#/components/schemas/GroupSummary'} + description: 仅资源所有者可见的直接分享分组,未分享时为空数组;不展开成员。 shared_users: type: array readOnly: true @@ -4393,6 +4455,11 @@ components: tags: type: array items: {$ref: '#/components/schemas/ResourceTag'} + shared_groups: + type: array + readOnly: true + items: {$ref: '#/components/schemas/GroupSummary'} + description: 仅资源所有者可见的直接分享分组,未分享时为空数组;不展开成员。 shared_users: type: array readOnly: true @@ -4478,6 +4545,11 @@ components: tags: type: array items: {$ref: '#/components/schemas/ResourceTag'} + shared_groups: + type: array + readOnly: true + items: {$ref: '#/components/schemas/GroupSummary'} + description: 仅资源所有者可见的直接分享分组,未分享时为空数组;不展开成员。 shared_users: type: array readOnly: true @@ -4653,6 +4725,11 @@ components: RuleResource: type: object properties: + shared_groups: + type: array + readOnly: true + items: {$ref: '#/components/schemas/GroupSummary'} + description: 仅资源所有者可见的直接分享分组,未分享时为空数组;不展开成员。 shared_users: type: array readOnly: true @@ -4717,6 +4794,11 @@ components: SkillResource: type: object properties: + shared_groups: + type: array + readOnly: true + items: {$ref: '#/components/schemas/GroupSummary'} + description: 仅资源所有者可见的直接分享分组,未分享时为空数组;不展开成员。 shared_users: type: array readOnly: true @@ -4799,6 +4881,11 @@ components: tags: type: array items: {$ref: '#/components/schemas/ResourceTag'} + shared_groups: + type: array + readOnly: true + items: {$ref: '#/components/schemas/GroupSummary'} + description: 仅资源所有者可见的直接分享分组,未分享时为空数组;不展开成员。 shared_users: type: array readOnly: true @@ -4900,6 +4987,11 @@ components: tags: type: array items: {$ref: '#/components/schemas/ResourceTag'} + shared_groups: + type: array + readOnly: true + items: {$ref: '#/components/schemas/GroupSummary'} + description: 仅资源所有者可见的直接分享分组,未分享时为空数组;不展开成员。 shared_users: type: array readOnly: true diff --git a/monkeyai/backend/internal/agentconfig/query.sql b/monkeyai/backend/internal/agentconfig/query.sql index d728524b9..6c4de6303 100644 --- a/monkeyai/backend/internal/agentconfig/query.sql +++ b/monkeyai/backend/internal/agentconfig/query.sql @@ -29,7 +29,7 @@ WITH RECURSIVE user_groups ( ) SELECT jsonb_build_object('kind', resource_type, 'id', resource_id, 'required', - bool_or(usage_requirement = 'required')) + bool_or(usage_requirement = 'required'), 'explicit', bool_or(NOT rag.all_users)) FROM resource_access_grants rag WHERE diff --git a/monkeyai/backend/internal/agentconfig/resources.go b/monkeyai/backend/internal/agentconfig/resources.go index ed92d1bdc..6000e24b8 100644 --- a/monkeyai/backend/internal/agentconfig/resources.go +++ b/monkeyai/backend/internal/agentconfig/resources.go @@ -73,20 +73,10 @@ func (r *Resources) load(ctx context.Context, q resource.Queryer, user, kind str if err != nil { return c, err } - personalRules := []string{} - for _, grant := range g { - if grant.String("kind") == "rule" && c.rules[grant.String("id")].String("ownership_type") == "user" { - personalRules = append(personalRules, grant.String("id")) - } - } - sharedRules, err := resource.SharedUsers(ctx, q, "rule", personalRules) - if err != nil { - return c, err - } for _, o := range g { if o.String("kind") == "rule" { rule := c.rules[o.String("id")] - if rule == nil || (rule.String("ownership_type") == "user" && !slices.ContainsFunc(sharedRules[o.String("id")], func(shared resource.Object) bool { return shared.String("id") == user })) { + if rule == nil || (rule.String("ownership_type") == "user" && !o.Bool("explicit")) { continue } } @@ -289,10 +279,15 @@ func (r *Resources) list(ctx context.Context, q resource.Queryer, user, kind str if err != nil { return nil, err } + groups, err := resource.SharedGroups(ctx, q, resourceType, owned) + if err != nil { + return nil, err + } for _, dto := range out { dto["user"] = people[ownerIDs[dto.String("id")]] if shared, ok := users[dto.String("id")]; ok { dto["shared_users"] = shared + dto["shared_groups"] = groups[dto.String("id")] } } resource.Stable(out) diff --git a/monkeyai/backend/internal/agentconfig/sqlc/query.sql.go b/monkeyai/backend/internal/agentconfig/sqlc/query.sql.go index d335c0b2b..52c5a6513 100644 --- a/monkeyai/backend/internal/agentconfig/sqlc/query.sql.go +++ b/monkeyai/backend/internal/agentconfig/sqlc/query.sql.go @@ -243,7 +243,7 @@ WITH RECURSIVE user_groups ( ) SELECT jsonb_build_object('kind', resource_type, 'id', resource_id, 'required', - bool_or(usage_requirement = 'required')) + bool_or(usage_requirement = 'required'), 'explicit', bool_or(NOT rag.all_users)) FROM resource_access_grants rag WHERE diff --git a/monkeyai/backend/internal/app/app.go b/monkeyai/backend/internal/app/app.go index 34d408174..430e4fa3f 100644 --- a/monkeyai/backend/internal/app/app.go +++ b/monkeyai/backend/internal/app/app.go @@ -182,6 +182,7 @@ func newApplicationHandler(ctx context.Context, logger *slog.Logger, pool *pgxpo endpoints := endpoint.NewService(endpoint.NewPostgres(pool), endpointAuth{identities}, logger, cfg.PublicURL).WithMaxConnections(cfg.EndpointMaxConnections) endpoints.RegisterAgent(agent) identities.RegisterAgent(agent) + group.NewService(pool).RegisterAgent(agent) keys.RegisterAgent(agent) models.RegisterAgent(agent) rules.RegisterAgent(agent) diff --git a/monkeyai/backend/internal/app/personal_test.go b/monkeyai/backend/internal/app/personal_test.go index 85836ec1b..7a8e689d1 100644 --- a/monkeyai/backend/internal/app/personal_test.go +++ b/monkeyai/backend/internal/app/personal_test.go @@ -267,6 +267,32 @@ func testPersonalResources(t *testing.T, pool *pgxpool.Pool, handler http.Handle if call("GET", expertPath+"/manifest", "b", "", nil, 200).Bool("available") { t.Fatal("撤销共享规则后专家仍可用") } + groupID := resource.ID() + if _, err := pool.Exec(t.Context(), `INSERT INTO groups(id,name) VALUES($1,'个人资源分享测试组')`, groupID); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(t.Context(), `INSERT INTO group_users(group_id,user_id,assigned_by_user_id) VALUES($1,$2,$3)`, groupID, users[1], users[0]); err != nil { + t.Fatal(err) + } + groupRule := resource.ShareInput{Resources: ruleShare.Resources, GroupIDs: []string{groupID}} + call("POST", "/resources/shares", "a", "", groupRule, 204) + if !contains("rules", "b", ruleID) || !call("GET", expertPath+"/manifest", "b", "", nil, 200).Bool("available") { + t.Fatal("分组授权的规则未使专家可用") + } + ownerRule := call("GET", "/rules/"+ruleID, "a", "", nil, 200) + if groups := ownerRule["shared_groups"].([]any); len(groups) != 1 || groups[0].(map[string]any)["id"] != groupID { + t.Fatalf("规则分组授权未回显: %v", ownerRule) + } + call("DELETE", "/resources/shares", "a", "", groupRule, 204) + if contains("rules", "b", ruleID) || call("GET", expertPath+"/manifest", "b", "", nil, 200).Bool("available") { + t.Fatal("分组规则撤销后权限未收回") + } + if _, err := pool.Exec(t.Context(), `DELETE FROM group_users WHERE group_id=$1`, groupID); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(t.Context(), `DELETE FROM groups WHERE id=$1`, groupID); err != nil { + t.Fatal(err) + } expert = call("GET", expertPath, "a", "", nil, 200) expert = call("PUT", expertPath, "a", etag(expert), resource.Object{"name": expert["name"], "rule_ids": []string{}}, 200) diff --git a/monkeyai/backend/internal/app/sharing_test.go b/monkeyai/backend/internal/app/sharing_test.go index b870d98d5..266912a62 100644 --- a/monkeyai/backend/internal/app/sharing_test.go +++ b/monkeyai/backend/internal/app/sharing_test.go @@ -14,6 +14,7 @@ import ( "github.com/chaitin/MonkeyCode/monkeyai/backend/internal/model" "github.com/chaitin/MonkeyCode/monkeyai/backend/internal/resource" + "github.com/chaitin/MonkeyCode/monkeyai/backend/internal/rootgroup" "github.com/jackc/pgx/v5/pgxpool" ) @@ -71,6 +72,7 @@ func testModelSharing(t *testing.T, pool *pgxpool.Pool, handler http.Handler, us } call("POST", "/models", "", input, 401) call("GET", "/users?q=example", "", nil, 401) + call("GET", "/groups?q=example", "", nil, 401) call("POST", "/resources/shares", "", resource.Object{}, 401) for _, value := range []string{"null", "{} {}", "[]"} { req := httptest.NewRequest("POST", "/api/v1/models", strings.NewReader(value)) @@ -225,6 +227,82 @@ func testModelSharing(t *testing.T, pool *pgxpool.Pool, handler http.Handler, us if len(revoked["models"].([]any)) != 0 || revokedHeaders.Get("ETag") == newHeadB.Get("ETag") { t.Fatal("撤销后配置仍包含模型或缓存未失效") } + groupID, childID := resource.ID(), resource.ID() + if _, err := pool.Exec(ctx, `INSERT INTO groups(id,parent_id,name) VALUES($1,NULL,'分享功能组'),($2,$1,'分享子组')`, groupID, childID); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM group_users WHERE group_id=$1`, childID) + _, _ = pool.Exec(context.Background(), `DELETE FROM groups WHERE id=$1`, childID) + _, _ = pool.Exec(context.Background(), `DELETE FROM groups WHERE id=$1`, groupID) + }) + if _, err := pool.Exec(ctx, `INSERT INTO group_users(group_id,user_id,assigned_by_user_id) VALUES($1,$2,$3)`, childID, users[1], adminID); err != nil { + t.Fatal(err) + } + call("GET", "/groups", "a", nil, 400) + call("GET", "/groups?q=%E5%88%86%E4%BA%AB&limit=101", "a", nil, 400) + foundGroups, _ := call("GET", "/groups?q=%E5%88%86%E4%BA%AB&limit=20", "a", nil, 200) + foundIDs := map[string]bool{} + for _, raw := range foundGroups["groups"].([]any) { + entry := raw.(map[string]any) + if len(entry) != 3 || entry["id"] == rootgroup.ID || entry["member_ids"] != nil { + t.Fatalf("组搜索返回多余信息: %v", entry) + } + foundIDs[entry["id"].(string)] = true + } + if !foundIDs[groupID] || !foundIDs[childID] { + t.Fatalf("组搜索未返回父子组: %v", foundGroups) + } + groupShare := resource.ShareInput{Resources: []resource.ShareResource{{Type: "model", ID: ids[1]}}, GroupIDs: []string{groupID, groupID}} + call("POST", "/resources/shares", "a", resource.ShareInput{Resources: groupShare.Resources, GroupIDs: []string{rootgroup.ID}}, 400) + call("POST", "/resources/shares", "a", resource.ShareInput{Resources: groupShare.Resources, GroupIDs: []string{groupID, resource.ID()}}, 400) + assertAccess(users[1], ids[1], false) + call("POST", "/resources/shares", "b", groupShare, 404) + call("POST", "/resources/shares", "a", groupShare, 204) + call("POST", "/resources/shares", "a", groupShare, 204) + assertAccess(users[1], ids[1], true) + owned, _ := call("GET", "/models/"+ids[1], "a", nil, 200) + groups := owned["shared_groups"].([]any) + if len(groups) != 1 || groups[0].(map[string]any)["id"] != groupID || len(owned["shared_users"].([]any)) != 0 { + t.Fatalf("模型详情组授权回显错误: %v", owned) + } + ownerCatalog, _ := call("GET", "/models", "a", nil, 200) + for _, raw := range ownerCatalog["models"].([]any) { + entry := raw.(map[string]any) + if entry["id"] == ids[1] && len(entry["shared_groups"].([]any)) != 1 { + t.Fatalf("模型目录组授权回显错误: %v", entry) + } + } + call("POST", "/resources/shares", "a", resource.ShareInput{Resources: groupShare.Resources, UserIDs: []string{users[1]}}, 204) + call("DELETE", "/resources/shares", "a", groupShare, 204) + assertAccess(users[1], ids[1], true) + call("DELETE", "/resources/shares", "a", resource.ShareInput{Resources: groupShare.Resources, UserIDs: []string{users[1]}}, 204) + assertAccess(users[1], ids[1], false) + call("POST", "/resources/shares", "a", groupShare, 204) + if _, err := pool.Exec(ctx, `UPDATE group_users SET removed_at=now() WHERE group_id=$1 AND user_id=$2`, childID, users[1]); err != nil { + t.Fatal(err) + } + assertAccess(users[1], ids[1], false) + if _, err := pool.Exec(ctx, `UPDATE group_users SET removed_at=NULL WHERE group_id=$1 AND user_id=$2`, childID, users[1]); err != nil { + t.Fatal(err) + } + assertAccess(users[1], ids[1], true) + if _, err := pool.Exec(ctx, `UPDATE groups SET parent_id=NULL WHERE id=$1`, childID); err != nil { + t.Fatal(err) + } + assertAccess(users[1], ids[1], false) + _, beforeDelete := call("GET", "/models", "a", nil, 200) + req := httptest.NewRequest("DELETE", "/api/admin/v1/groups/"+groupID, nil) + req.Header.Set("Authorization", "Bearer sharing-admin") + response := httptest.NewRecorder() + handler.ServeHTTP(response, req) + if response.Code != 204 { + t.Fatalf("删除共享分组失败: %d %s", response.Code, response.Body.String()) + } + _, afterDelete := call("GET", "/models", "a", nil, 200) + if beforeDelete.Get("ETag") == afterDelete.Get("ETag") { + t.Fatal("删除分组后模型目录缓存未更新") + } call("POST", "/resources/shares", "a", share, 204) call("DELETE", "/models/"+ids[0], "a", nil, 204) assertAccess(users[0], ids[0], false) diff --git a/monkeyai/backend/internal/group/agent.go b/monkeyai/backend/internal/group/agent.go new file mode 100644 index 000000000..ee1bcf59d --- /dev/null +++ b/monkeyai/backend/internal/group/agent.go @@ -0,0 +1,54 @@ +package group + +import ( + "context" + "net/http" + "strconv" + "strings" + + "github.com/chaitin/MonkeyCode/monkeyai/backend/internal/group/sqlc" + "github.com/chaitin/MonkeyCode/monkeyai/backend/internal/resource" + "github.com/chaitin/MonkeyCode/monkeyai/backend/internal/rootgroup" + "github.com/go-chi/chi/v5" +) + +type GroupSummary struct { + ID string `json:"id"` + Name string `json:"name"` + ParentID *string `json:"parent_id"` +} + +func (s *Service) RegisterAgent(router chi.Router) { + router.Get("/groups", func(w http.ResponseWriter, r *http.Request) { + query := strings.TrimSpace(r.URL.Query().Get("q")) + limit := 20 + var err error + if raw := r.URL.Query().Get("limit"); raw != "" { + limit, err = strconv.Atoi(raw) + } + if err != nil || limit < 1 || limit > 100 || query == "" || len(query) > 200 { + resource.Fail(w, resource.Invalid("q 必须为 1—200 字节,limit 必须为 1—100")) + return + } + groups, err := s.Search(r.Context(), query, limit) + if err != nil { + resource.Fail(w, err) + return + } + resource.JSON(w, http.StatusOK, map[string]any{"groups": groups}) + }) +} + +func (s *Service) Search(ctx context.Context, query string, limit int) ([]GroupSummary, error) { + rows, err := sqlc.New(s.pool).SearchGroups(ctx, sqlc.SearchGroupsParams{ + RootID: rootgroup.ID, NameQuery: query, ResultLimit: int32(limit), + }) + if err != nil { + return nil, err + } + groups := []GroupSummary{} + for _, row := range rows { + groups = append(groups, GroupSummary{ID: row.ID, Name: row.Name, ParentID: rootgroup.ParentID(row.ParentID)}) + } + return groups, nil +} diff --git a/monkeyai/backend/internal/group/query.sql b/monkeyai/backend/internal/group/query.sql index b3ecd38d8..78de89e9b 100644 --- a/monkeyai/backend/internal/group/query.sql +++ b/monkeyai/backend/internal/group/query.sql @@ -23,6 +23,22 @@ SELECT g.created_at, g.id; +-- name: SearchGroups :many +SELECT + g.id, + g.name, + g.parent_id +FROM + groups g +WHERE + g.deleted_at IS NULL + AND g.id <> sqlc.arg(root_id)::uuid + AND strpos(lower(g.name), lower(sqlc.arg(name_query)::text)) > 0 +ORDER BY + lower(g.name), + g.id +LIMIT sqlc.arg(result_limit)::int; + -- name: GetGroup :one SELECT g.id, @@ -179,6 +195,26 @@ WHERE group_id = $1 AND removed_at IS NULL; +-- name: TouchGrantedModels :execresult +UPDATE models SET updated_at = now() +WHERE id IN (SELECT resource_id FROM resource_access_grants WHERE group_id = $1 AND resource_type = 'model'); + +-- name: TouchGrantedRules :execresult +UPDATE rules SET updated_at = now(), revision = revision + 1 +WHERE id IN (SELECT resource_id FROM resource_access_grants WHERE group_id = $1 AND resource_type = 'rule'); + +-- name: TouchGrantedSkills :execresult +UPDATE skills SET updated_at = now(), revision = revision + 1 +WHERE id IN (SELECT resource_id FROM resource_access_grants WHERE group_id = $1 AND resource_type = 'skill'); + +-- name: TouchGrantedConnectors :execresult +UPDATE connectors SET updated_at = now(), revision = revision + 1 +WHERE id IN (SELECT resource_id FROM resource_access_grants WHERE group_id = $1 AND resource_type = 'connector'); + +-- name: TouchGrantedExperts :execresult +UPDATE experts SET updated_at = now(), revision = revision + 1 +WHERE id IN (SELECT resource_id FROM resource_access_grants WHERE group_id = $1 AND resource_type = 'expert'); + -- name: DeleteGrants :execresult DELETE FROM resource_access_grants WHERE group_id = $1; diff --git a/monkeyai/backend/internal/group/service.go b/monkeyai/backend/internal/group/service.go index edcd53230..dde30b7be 100644 --- a/monkeyai/backend/internal/group/service.go +++ b/monkeyai/backend/internal/group/service.go @@ -264,6 +264,9 @@ func (s *Service) Delete(ctx context.Context, actor, id string) error { if _, err = sqlc.New(tx).RemoveAllMembers(ctx, id); err != nil { return err } + if err = touchShared(ctx, tx, id); err != nil { + return err + } if _, err = sqlc.New(tx).DeleteGrants(ctx, new(id)); err != nil { return err } @@ -273,6 +276,24 @@ func (s *Service) Delete(ctx context.Context, actor, id string) error { return tx.Commit(ctx) } +func touchShared(ctx context.Context, tx pgx.Tx, groupID string) error { + q := sqlc.New(tx) + if _, err := q.TouchGrantedModels(ctx, new(groupID)); err != nil { + return err + } + if _, err := q.TouchGrantedRules(ctx, new(groupID)); err != nil { + return err + } + if _, err := q.TouchGrantedSkills(ctx, new(groupID)); err != nil { + return err + } + if _, err := q.TouchGrantedConnectors(ctx, new(groupID)); err != nil { + return err + } + _, err := q.TouchGrantedExperts(ctx, new(groupID)) + return err +} + func rollbackGroup(ctx context.Context, tx pgx.Tx, operation, id string) { if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) && ctx.Err() == nil { slog.ErrorContext(ctx, "回滚分组事务失败", "operation", operation, "group_id", id, "error", err) diff --git a/monkeyai/backend/internal/group/sqlc/query.sql.go b/monkeyai/backend/internal/group/sqlc/query.sql.go index 352e9862d..ef60619ac 100644 --- a/monkeyai/backend/internal/group/sqlc/query.sql.go +++ b/monkeyai/backend/internal/group/sqlc/query.sql.go @@ -432,6 +432,100 @@ func (q *Queries) RootUserGroups(ctx context.Context, rootID string) ([]RootUser return items, nil } +const searchGroups = `-- name: SearchGroups :many +SELECT + g.id, + g.name, + g.parent_id +FROM + groups g +WHERE + g.deleted_at IS NULL + AND g.id <> $1::uuid + AND strpos(lower(g.name), lower($2::text)) > 0 +ORDER BY + lower(g.name), + g.id +LIMIT $3::int +` + +type SearchGroupsParams struct { + RootID string + NameQuery string + ResultLimit int32 +} + +type SearchGroupsRow struct { + ID string + Name string + ParentID *string +} + +func (q *Queries) SearchGroups(ctx context.Context, arg SearchGroupsParams) ([]SearchGroupsRow, error) { + rows, err := q.db.Query(ctx, searchGroups, arg.RootID, arg.NameQuery, arg.ResultLimit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []SearchGroupsRow{} + for rows.Next() { + var i SearchGroupsRow + if err := rows.Scan(&i.ID, &i.Name, &i.ParentID); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const touchGrantedConnectors = `-- name: TouchGrantedConnectors :execresult +UPDATE connectors SET updated_at = now(), revision = revision + 1 +WHERE id IN (SELECT resource_id FROM resource_access_grants WHERE group_id = $1 AND resource_type = 'connector') +` + +func (q *Queries) TouchGrantedConnectors(ctx context.Context, groupID *string) (pgconn.CommandTag, error) { + return q.db.Exec(ctx, touchGrantedConnectors, groupID) +} + +const touchGrantedExperts = `-- name: TouchGrantedExperts :execresult +UPDATE experts SET updated_at = now(), revision = revision + 1 +WHERE id IN (SELECT resource_id FROM resource_access_grants WHERE group_id = $1 AND resource_type = 'expert') +` + +func (q *Queries) TouchGrantedExperts(ctx context.Context, groupID *string) (pgconn.CommandTag, error) { + return q.db.Exec(ctx, touchGrantedExperts, groupID) +} + +const touchGrantedModels = `-- name: TouchGrantedModels :execresult +UPDATE models SET updated_at = now() +WHERE id IN (SELECT resource_id FROM resource_access_grants WHERE group_id = $1 AND resource_type = 'model') +` + +func (q *Queries) TouchGrantedModels(ctx context.Context, groupID *string) (pgconn.CommandTag, error) { + return q.db.Exec(ctx, touchGrantedModels, groupID) +} + +const touchGrantedRules = `-- name: TouchGrantedRules :execresult +UPDATE rules SET updated_at = now(), revision = revision + 1 +WHERE id IN (SELECT resource_id FROM resource_access_grants WHERE group_id = $1 AND resource_type = 'rule') +` + +func (q *Queries) TouchGrantedRules(ctx context.Context, groupID *string) (pgconn.CommandTag, error) { + return q.db.Exec(ctx, touchGrantedRules, groupID) +} + +const touchGrantedSkills = `-- name: TouchGrantedSkills :execresult +UPDATE skills SET updated_at = now(), revision = revision + 1 +WHERE id IN (SELECT resource_id FROM resource_access_grants WHERE group_id = $1 AND resource_type = 'skill') +` + +func (q *Queries) TouchGrantedSkills(ctx context.Context, groupID *string) (pgconn.CommandTag, error) { + return q.db.Exec(ctx, touchGrantedSkills, groupID) +} + const touchGroup = `-- name: TouchGroup :execresult UPDATE GROUPS diff --git a/monkeyai/backend/internal/model/model.go b/monkeyai/backend/internal/model/model.go index 896c47159..51793eefb 100644 --- a/monkeyai/backend/internal/model/model.go +++ b/monkeyai/backend/internal/model/model.go @@ -84,53 +84,55 @@ type Authorization struct { } type Model struct { - Creator *Subject `json:"-"` - SharedUsers []Subject `json:"-"` - ID string `json:"id"` - OwnershipType string `json:"ownership_type"` - OwnerUserID string `json:"-"` - User resource.User `json:"user"` - ModelID string `json:"model_id"` - DisplayName string `json:"display_name"` - Protocol Protocol `json:"protocol"` - Kind Kind `json:"kind"` - Provider Provider `json:"provider"` - ProviderOptions json.RawMessage `json:"-"` - ImageConfig *ImageConfig `json:"image_config,omitempty"` - ImagePricing *ImagePricing `json:"image_pricing,omitempty"` - BaseURL string `json:"base_url"` - APIKey string `json:"-"` - GrantorUserID string `json:"-"` - APIKeyConfigured bool `json:"api_key_configured"` - AdvancedConfig AdvancedConfig `json:"advanced_config"` - CreditMultiplier float64 `json:"credit_multiplier"` - Authorization Authorization `json:"authorization"` - Tags []resource.Object `json:"tags"` - TagIDs []string `json:"-"` - Enabled bool `json:"enabled"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + Creator *Subject `json:"-"` + SharedUsers []Subject `json:"-"` + SharedGroups *[]resource.Object `json:"shared_groups,omitempty"` + ID string `json:"id"` + OwnershipType string `json:"ownership_type"` + OwnerUserID string `json:"-"` + User resource.User `json:"user"` + ModelID string `json:"model_id"` + DisplayName string `json:"display_name"` + Protocol Protocol `json:"protocol"` + Kind Kind `json:"kind"` + Provider Provider `json:"provider"` + ProviderOptions json.RawMessage `json:"-"` + ImageConfig *ImageConfig `json:"image_config,omitempty"` + ImagePricing *ImagePricing `json:"image_pricing,omitempty"` + BaseURL string `json:"base_url"` + APIKey string `json:"-"` + GrantorUserID string `json:"-"` + APIKeyConfigured bool `json:"api_key_configured"` + AdvancedConfig AdvancedConfig `json:"advanced_config"` + CreditMultiplier float64 `json:"credit_multiplier"` + Authorization Authorization `json:"authorization"` + Tags []resource.Object `json:"tags"` + TagIDs []string `json:"-"` + Enabled bool `json:"enabled"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type AgentModel struct { - OwnershipType string `json:"ownership_type"` - User resource.User `json:"user"` - Creator *Subject `json:"creator,omitempty"` - SharedUsers *[]Subject `json:"shared_users,omitempty"` - ID string `json:"id"` - Model string `json:"model"` - DisplayName string `json:"display_name"` - Protocol Protocol `json:"protocol"` - Kind Kind `json:"kind"` - ImageConfig *AgentImageConfig `json:"image_config,omitempty"` - ImagePricing *ImagePricing `json:"image_pricing,omitempty"` - ContextWindowTokens int64 `json:"context_window_tokens"` - MaxOutputTokens int64 `json:"max_output_tokens"` - SupportsVision bool `json:"supports_vision"` - SupportsReasoning bool `json:"supports_reasoning"` - CreditMultiplier float64 `json:"credit_multiplier"` - Tags []resource.Object `json:"tags"` - UpdatedAt time.Time `json:"-"` + OwnershipType string `json:"ownership_type"` + User resource.User `json:"user"` + Creator *Subject `json:"creator,omitempty"` + SharedUsers *[]Subject `json:"shared_users,omitempty"` + SharedGroups *[]resource.Object `json:"shared_groups,omitempty"` + ID string `json:"id"` + Model string `json:"model"` + DisplayName string `json:"display_name"` + Protocol Protocol `json:"protocol"` + Kind Kind `json:"kind"` + ImageConfig *AgentImageConfig `json:"image_config,omitempty"` + ImagePricing *ImagePricing `json:"image_pricing,omitempty"` + ContextWindowTokens int64 `json:"context_window_tokens"` + MaxOutputTokens int64 `json:"max_output_tokens"` + SupportsVision bool `json:"supports_vision"` + SupportsReasoning bool `json:"supports_reasoning"` + CreditMultiplier float64 `json:"credit_multiplier"` + Tags []resource.Object `json:"tags"` + UpdatedAt time.Time `json:"-"` } type Target struct { diff --git a/monkeyai/backend/internal/model/postgres.go b/monkeyai/backend/internal/model/postgres.go index 6dba6ef1a..f91970a42 100644 --- a/monkeyai/backend/internal/model/postgres.go +++ b/monkeyai/backend/internal/model/postgres.go @@ -398,6 +398,19 @@ func (p *Postgres) loadGrants(ctx context.Context, models []Model) error { item.Authorization.GroupIDs = append(item.Authorization.GroupIDs, *groupID) } } + personal := []string{} + for _, item := range models { + if item.OwnershipType == "user" { + personal = append(personal, item.ID) + } + } + groups, err := resource.SharedGroups(ctx, database.Reader(ctx, p.pool), "model", personal) + if err != nil { + return err + } + for id, shared := range groups { + byID[id].SharedGroups = &shared + } return nil } @@ -485,6 +498,19 @@ func (p *Postgres) loadPeople(ctx context.Context, models []Model, actor string) byID[id].SharedUsers = append(byID[id].SharedUsers, person) } } + owned := []string{} + for _, item := range models { + if item.OwnershipType == "user" && item.OwnerUserID == actor { + owned = append(owned, item.ID) + } + } + groups, err := resource.SharedGroups(ctx, database.Reader(ctx, p.pool), "model", owned) + if err != nil { + return err + } + for id, shared := range groups { + byID[id].SharedGroups = &shared + } return nil } diff --git a/monkeyai/backend/internal/model/service.go b/monkeyai/backend/internal/model/service.go index dc94d6450..bcd5bd6c5 100644 --- a/monkeyai/backend/internal/model/service.go +++ b/monkeyai/backend/internal/model/service.go @@ -208,6 +208,7 @@ func (s *Service) AgentModels(ctx context.Context, userID string, isAdmin bool) users = []Subject{} } entry.SharedUsers = &users + entry.SharedGroups = item.SharedGroups } else { entry.Creator = item.Creator } diff --git a/monkeyai/backend/internal/resource/query.sql b/monkeyai/backend/internal/resource/query.sql index 204a40082..ada09facd 100644 --- a/monkeyai/backend/internal/resource/query.sql +++ b/monkeyai/backend/internal/resource/query.sql @@ -88,6 +88,24 @@ WHERE ORDER BY id FOR SHARE; +-- name: LockShareGroups :many +SELECT id FROM groups +WHERE id::text = ANY (sqlc.arg(group_ids)::text[]) AND deleted_at IS NULL +ORDER BY id FOR SHARE; + +-- name: RevokeGroupShares :execresult +DELETE FROM resource_access_grants +WHERE resource_type = sqlc.arg(resource_type) + AND resource_id = sqlc.arg(resource_id) + AND group_id::text = ANY (sqlc.arg(group_ids)::text[]); + +-- name: CreateGroupShares :execresult +INSERT INTO resource_access_grants (resource_type, resource_id, group_id, access_level, granted_by_user_id) +SELECT sqlc.arg(resource_type), sqlc.arg(resource_id), g.id, 'read_only', sqlc.arg(granted_by_user_id) +FROM groups g +WHERE g.id::text = ANY (sqlc.arg(group_ids)::text[]) AND g.deleted_at IS NULL +ON CONFLICT (resource_type, resource_id, group_id) WHERE group_id IS NOT NULL DO NOTHING; + -- name: RevokeShares :execresult DELETE FROM resource_access_grants WHERE resource_type = sqlc.arg(resource_type) @@ -164,10 +182,12 @@ SELECT -- name: ListGrants :many SELECT jsonb_build_object('user_id', rag.user_id, 'group_id', rag.group_id, 'all_users', rag.all_users, 'usage_requirement', rag.usage_requirement, - 'user', CASE WHEN u.id IS NOT NULL THEN jsonb_build_object('id', u.id, 'name', u.name, 'email', u.email) END) + 'user', CASE WHEN u.id IS NOT NULL THEN jsonb_build_object('id', u.id, 'name', u.name, 'email', u.email) END, + 'group', CASE WHEN g.id IS NOT NULL THEN jsonb_build_object('id', g.id, 'name', g.name, 'parent_id', COALESCE(g.parent_id::text, '00000000-0000-0000-0000-000000000000')) END) FROM resource_access_grants rag LEFT JOIN users u ON u.id = rag.user_id AND u.deleted_at IS NULL + LEFT JOIN groups g ON g.id = rag.group_id AND g.deleted_at IS NULL WHERE rag.resource_type = $1 AND rag.resource_id = $2 @@ -191,6 +211,14 @@ ORDER BY rag.resource_id, u.id; +-- name: ListSharedGroups :many +SELECT rag.resource_id, g.id, g.name, COALESCE(g.parent_id, '00000000-0000-0000-0000-000000000000'::uuid) AS parent_id +FROM resource_access_grants rag +JOIN groups g ON g.id = rag.group_id AND g.deleted_at IS NULL +WHERE rag.resource_type = sqlc.arg(resource_type) + AND rag.resource_id::text = ANY (sqlc.arg(resource_ids)::text[]) +ORDER BY rag.resource_id, g.id; + -- name: DeleteGrants :execresult DELETE FROM resource_access_grants WHERE resource_type = $1 diff --git a/monkeyai/backend/internal/resource/sharing.go b/monkeyai/backend/internal/resource/sharing.go index 86454b980..8ef5cd5c1 100644 --- a/monkeyai/backend/internal/resource/sharing.go +++ b/monkeyai/backend/internal/resource/sharing.go @@ -8,6 +8,7 @@ import ( "github.com/chaitin/MonkeyCode/monkeyai/backend/internal/identity" "github.com/chaitin/MonkeyCode/monkeyai/backend/internal/resource/sqlc" + "github.com/chaitin/MonkeyCode/monkeyai/backend/internal/rootgroup" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5" @@ -26,6 +27,7 @@ type ShareResource struct { type ShareInput struct { Resources []ShareResource `json:"resources"` UserIDs []string `json:"user_ids"` + GroupIDs []string `json:"group_ids"` } func SharedUsers(ctx context.Context, q Queryer, kind string, ids []string) (map[string][]Object, error) { @@ -46,6 +48,24 @@ func SharedUsers(ctx context.Context, q Queryer, kind string, ids []string) (map return users, nil } +func SharedGroups(ctx context.Context, q Queryer, kind string, ids []string) (map[string][]Object, error) { + groups := make(map[string][]Object, len(ids)) + if len(ids) == 0 { + return groups, nil + } + for _, id := range ids { + groups[id] = []Object{} + } + rows, err := sqlc.New(q).ListSharedGroups(ctx, sqlc.ListSharedGroupsParams{ResourceType: kind, ResourceIds: ids}) + if err != nil { + return nil, err + } + for _, row := range rows { + groups[row.ResourceID] = append(groups[row.ResourceID], Object{"id": row.ID, "name": row.Name, "parent_id": row.ParentID}) + } + return groups, nil +} + func (s *Store) RegisterSharing(router chi.Router, kinds map[string]Shareable) { share := func(w http.ResponseWriter, r *http.Request) { var input ShareInput @@ -65,11 +85,12 @@ func (s *Store) RegisterSharing(router chi.Router, kinds map[string]Shareable) { } func (s *Store) Share(ctx context.Context, actor string, input ShareInput, revoke bool, kinds map[string]Shareable) error { - if len(input.Resources) == 0 || len(input.Resources) > 100 || len(input.UserIDs) == 0 || len(input.UserIDs) > 100 { - return Invalid("resources 和 user_ids 必须各包含 1—100 项") + if len(input.Resources) == 0 || len(input.Resources) > 100 || len(input.UserIDs)+len(input.GroupIDs) == 0 || len(input.UserIDs)+len(input.GroupIDs) > 100 { + return Invalid("resources 必须包含 1—100 项,user_ids 和 group_ids 合计必须包含 1—100 项") } input.Resources = slices.Clone(input.Resources) input.UserIDs = slices.Clone(input.UserIDs) + input.GroupIDs = slices.Clone(input.GroupIDs) for _, item := range input.Resources { if kinds[item.Type] == nil { return Invalid("不支持分享此资源类型") @@ -86,12 +107,20 @@ func (s *Store) Share(ctx context.Context, actor string, input ShareInput, revok return Invalid("不能分享给自己") } } + for _, id := range input.GroupIDs { + if !validUUID(id) || strings.EqualFold(id, rootgroup.ID) { + return Invalid("分组 ID 必须是真实分组的 UUID") + } + } for i := range input.Resources { input.Resources[i].ID = strings.ToLower(input.Resources[i].ID) } for i := range input.UserIDs { input.UserIDs[i] = strings.ToLower(input.UserIDs[i]) } + for i := range input.GroupIDs { + input.GroupIDs[i] = strings.ToLower(input.GroupIDs[i]) + } slices.SortFunc(input.Resources, func(a, b ShareResource) int { if n := strings.Compare(a.Type, b.Type); n != 0 { return n @@ -101,18 +130,29 @@ func (s *Store) Share(ctx context.Context, actor string, input ShareInput, revok input.Resources = slices.Compact(input.Resources) slices.Sort(input.UserIDs) input.UserIDs = slices.Compact(input.UserIDs) + slices.Sort(input.GroupIDs) + input.GroupIDs = slices.Compact(input.GroupIDs) tx, err := s.Pool.Begin(ctx) if err != nil { return err } defer func() { rollback(ctx, tx, "share", input.Resources[0].ID) }() + if !revoke && len(input.GroupIDs) > 0 { + groups, err := sqlc.New(tx).LockShareGroups(ctx, input.GroupIDs) + if err != nil { + return err + } + if len(groups) != len(input.GroupIDs) { + return Invalid("接收分组不存在或已删除") + } + } // 固定加锁顺序,批量授权和删除共享资源行锁。 for _, item := range input.Resources { if err := kinds[item.Type].LockOwned(ctx, tx, item.ID, actor); err != nil { return err } } - if !revoke { + if !revoke && len(input.UserIDs) > 0 { rows, err := sqlc.New(tx).LockRecipients(ctx, input.UserIDs) if err != nil { return err @@ -124,9 +164,19 @@ func (s *Store) Share(ctx context.Context, actor string, input ShareInput, revok } for _, item := range input.Resources { if revoke { - _, err = sqlc.New(tx).RevokeShares(ctx, sqlc.RevokeSharesParams{ResourceType: item.Type, ResourceID: item.ID, UserIds: input.UserIDs}) + if len(input.UserIDs) > 0 { + _, err = sqlc.New(tx).RevokeShares(ctx, sqlc.RevokeSharesParams{ResourceType: item.Type, ResourceID: item.ID, UserIds: input.UserIDs}) + } + if err == nil && len(input.GroupIDs) > 0 { + _, err = sqlc.New(tx).RevokeGroupShares(ctx, sqlc.RevokeGroupSharesParams{ResourceType: item.Type, ResourceID: item.ID, GroupIds: input.GroupIDs}) + } } else { - _, err = sqlc.New(tx).CreateShares(ctx, sqlc.CreateSharesParams{ResourceType: item.Type, ResourceID: item.ID, GrantedByUserID: actor, UserIds: input.UserIDs}) + if len(input.UserIDs) > 0 { + _, err = sqlc.New(tx).CreateShares(ctx, sqlc.CreateSharesParams{ResourceType: item.Type, ResourceID: item.ID, GrantedByUserID: actor, UserIds: input.UserIDs}) + } + if err == nil && len(input.GroupIDs) > 0 { + _, err = sqlc.New(tx).CreateGroupShares(ctx, sqlc.CreateGroupSharesParams{ResourceType: item.Type, ResourceID: item.ID, GrantedByUserID: actor, GroupIds: input.GroupIDs}) + } } if err != nil { return err diff --git a/monkeyai/backend/internal/resource/sqlc/query.sql.go b/monkeyai/backend/internal/resource/sqlc/query.sql.go index 04974869a..23bfa3d2e 100644 --- a/monkeyai/backend/internal/resource/sqlc/query.sql.go +++ b/monkeyai/backend/internal/resource/sqlc/query.sql.go @@ -82,6 +82,30 @@ func (q *Queries) CreateGrant(ctx context.Context, arg CreateGrantParams) (pgcon ) } +const createGroupShares = `-- name: CreateGroupShares :execresult +INSERT INTO resource_access_grants (resource_type, resource_id, group_id, access_level, granted_by_user_id) +SELECT $1, $2, g.id, 'read_only', $3 +FROM groups g +WHERE g.id::text = ANY ($4::text[]) AND g.deleted_at IS NULL +ON CONFLICT (resource_type, resource_id, group_id) WHERE group_id IS NOT NULL DO NOTHING +` + +type CreateGroupSharesParams struct { + ResourceType string + ResourceID string + GrantedByUserID string + GroupIds []string +} + +func (q *Queries) CreateGroupShares(ctx context.Context, arg CreateGroupSharesParams) (pgconn.CommandTag, error) { + return q.db.Exec(ctx, createGroupShares, + arg.ResourceType, + arg.ResourceID, + arg.GrantedByUserID, + arg.GroupIds, + ) +} + const createShares = `-- name: CreateShares :execresult INSERT INTO resource_access_grants (resource_type, resource_id, user_id, access_level, granted_by_user_id) SELECT @@ -237,10 +261,12 @@ func (q *Queries) HasAccess(ctx context.Context, arg HasAccessParams) (bool, err const listGrants = `-- name: ListGrants :many SELECT jsonb_build_object('user_id', rag.user_id, 'group_id', rag.group_id, 'all_users', rag.all_users, 'usage_requirement', rag.usage_requirement, - 'user', CASE WHEN u.id IS NOT NULL THEN jsonb_build_object('id', u.id, 'name', u.name, 'email', u.email) END) + 'user', CASE WHEN u.id IS NOT NULL THEN jsonb_build_object('id', u.id, 'name', u.name, 'email', u.email) END, + 'group', CASE WHEN g.id IS NOT NULL THEN jsonb_build_object('id', g.id, 'name', g.name, 'parent_id', COALESCE(g.parent_id::text, '00000000-0000-0000-0000-000000000000')) END) FROM resource_access_grants rag LEFT JOIN users u ON u.id = rag.user_id AND u.deleted_at IS NULL + LEFT JOIN groups g ON g.id = rag.group_id AND g.deleted_at IS NULL WHERE rag.resource_type = $1 AND rag.resource_id = $2 @@ -341,6 +367,52 @@ func (q *Queries) ListOwners(ctx context.Context, dollar_1 []string) ([]ListOwne return items, nil } +const listSharedGroups = `-- name: ListSharedGroups :many +SELECT rag.resource_id, g.id, g.name, COALESCE(g.parent_id, '00000000-0000-0000-0000-000000000000'::uuid) AS parent_id +FROM resource_access_grants rag +JOIN groups g ON g.id = rag.group_id AND g.deleted_at IS NULL +WHERE rag.resource_type = $1 + AND rag.resource_id::text = ANY ($2::text[]) +ORDER BY rag.resource_id, g.id +` + +type ListSharedGroupsParams struct { + ResourceType string + ResourceIds []string +} + +type ListSharedGroupsRow struct { + ResourceID string + ID string + Name string + ParentID *string +} + +func (q *Queries) ListSharedGroups(ctx context.Context, arg ListSharedGroupsParams) ([]ListSharedGroupsRow, error) { + rows, err := q.db.Query(ctx, listSharedGroups, arg.ResourceType, arg.ResourceIds) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListSharedGroupsRow{} + for rows.Next() { + var i ListSharedGroupsRow + if err := rows.Scan( + &i.ResourceID, + &i.ID, + &i.Name, + &i.ParentID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listSharedUsers = `-- name: ListSharedUsers :many SELECT rag.resource_id, @@ -493,6 +565,32 @@ func (q *Queries) LockRecipients(ctx context.Context, dollar_1 []string) ([]stri return items, nil } +const lockShareGroups = `-- name: LockShareGroups :many +SELECT id FROM groups +WHERE id::text = ANY ($1::text[]) AND deleted_at IS NULL +ORDER BY id FOR SHARE +` + +func (q *Queries) LockShareGroups(ctx context.Context, groupIds []string) ([]string, error) { + rows, err := q.db.Query(ctx, lockShareGroups, groupIds) + if err != nil { + return nil, err + } + defer rows.Close() + items := []string{} + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + items = append(items, id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const removeResourceTags = `-- name: RemoveResourceTags :exec DELETE FROM resource_tags WHERE resource_type = $1 AND resource_id = $2 ` @@ -539,6 +637,23 @@ func (q *Queries) ResourceTags(ctx context.Context, arg ResourceTagsParams) ([][ return items, nil } +const revokeGroupShares = `-- name: RevokeGroupShares :execresult +DELETE FROM resource_access_grants +WHERE resource_type = $1 + AND resource_id = $2 + AND group_id::text = ANY ($3::text[]) +` + +type RevokeGroupSharesParams struct { + ResourceType string + ResourceID string + GroupIds []string +} + +func (q *Queries) RevokeGroupShares(ctx context.Context, arg RevokeGroupSharesParams) (pgconn.CommandTag, error) { + return q.db.Exec(ctx, revokeGroupShares, arg.ResourceType, arg.ResourceID, arg.GroupIds) +} + const revokeShares = `-- name: RevokeShares :execresult DELETE FROM resource_access_grants WHERE resource_type = $1 diff --git a/monkeyai/backend/internal/resource/store.go b/monkeyai/backend/internal/resource/store.go index e30a536fa..2bfd4d066 100644 --- a/monkeyai/backend/internal/resource/store.go +++ b/monkeyai/backend/internal/resource/store.go @@ -251,13 +251,17 @@ func (c *CRUD) decorate(ctx context.Context, q Queryer, o Object) (Object, error } o["grants"] = g if o.String("ownership_type") == "user" { - users := []any{} + users, groups := []any{}, []any{} for _, grant := range g { if user := grant["user"]; user != nil { users = append(users, user) } + if group := grant["group"]; group != nil { + groups = append(groups, group) + } } o["shared_users"] = users + o["shared_groups"] = groups } if owner := o.String("owner_user_id"); owner != "" { users, err := Users(ctx, q, []string{owner})