addaa1b34f
引入 Space 中间容器(租户>Space>成员),资源作用域从 owner 改为 space_id,
让"个人私有/项目临时组队/整租户共享"出自同一模型(设计见 SPACE_DESIGN.md)。
先在纯 PG 的 Agent 上打样,零存储风险,验证协作+RBAC+切换 UX。
后端:
- 新表 Space{tenant_id,name,kind,creator,archived} + SpaceMember{space_id,user_id,role}
(如 Tenant 般不 isTenantScoped);User.ActiveSpaceID;Agent 作用域 owner→space_id,
owner 降级为创建人(供 UI 显示 / 删他人鉴权)
- store/space.go:个人空间幂等/活跃空间解析/切换/列表/建/成员CRUD/归档
- 迁移顺序坑:结构体只放非唯一 index,MigrateAgentSpaces 回填 space_id 后再建唯一
索引 idx_agent_sn + DROP 旧 idx_agent_on(否则存量空 space_id 撞车);启动序4步幂等
- 中间件 SpaceContext(注入 space_id) + RequireSpaceRole(照 RequireTenantRole)
- handler/space.go 空间端点 + 路由;agent.go 改空间作用域(删/覆盖他人需 admin)
- 计费零改动(Space 与 ResolveBillingTenantID 正交)
桌面端:
- api.ts space 接口;顶栏 SpaceSwitcher(含新建项目空间);StudioView 随空间切换
重拉编排 + viewer 禁保存;Agent 列表显示创建人 + 按 mine 控删除
验证:中间件6门控单测 + DB迁移(13个人空间/9 Agent全re-key/索引换新) + 后端HTTP全
场景(member见他人编排/删他人403、viewer存403、非成员切空间400+隔离、owner删他人200)
+ 浏览器实机(切换器3空间/Studio空间编排随切换隔离刷新/创建人显示/console无错)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
61 lines
1.7 KiB
Go
61 lines
1.7 KiB
Go
package middleware
|
||
|
||
import (
|
||
"context"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"testing"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
|
||
"github.com/sundynix/sundynix-gateway/internal/store"
|
||
)
|
||
|
||
type fakeSpaceRole struct{ role string }
|
||
|
||
func (f fakeSpaceRole) SpaceMemberRole(context.Context, string, string) string { return f.role }
|
||
|
||
func spaceGateEngine(uid, space, memberRole, minRole string) *gin.Engine {
|
||
gin.SetMode(gin.TestMode)
|
||
r := gin.New()
|
||
r.Use(func(c *gin.Context) {
|
||
if uid != "" {
|
||
c.Set(CtxUserID, uid)
|
||
}
|
||
if space != "" {
|
||
c.Set(CtxSpaceID, space)
|
||
}
|
||
c.Next()
|
||
})
|
||
r.POST("/agents", RequireSpaceRole(fakeSpaceRole{role: memberRole}, minRole),
|
||
func(c *gin.Context) { c.String(http.StatusOK, "saved") })
|
||
return r
|
||
}
|
||
|
||
func TestRequireSpaceRole_Gate(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
uid string
|
||
space string
|
||
memberRole string
|
||
wantCode int
|
||
}{
|
||
{"owner 放行", "u1", "s1", store.RoleOwner, http.StatusOK},
|
||
{"admin 放行", "u1", "s1", store.RoleAdmin, http.StatusOK},
|
||
{"member 放行", "u1", "s1", store.RoleMember, http.StatusOK},
|
||
{"viewer 拦下(空间只读)", "u1", "s1", store.RoleViewer, http.StatusForbidden},
|
||
{"非空间成员 拦下", "u1", "s1", "", http.StatusForbidden},
|
||
{"未登录 401", "", "s1", store.RoleOwner, http.StatusUnauthorized},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
r := spaceGateEngine(tc.uid, tc.space, tc.memberRole, store.RoleMember)
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/agents", nil))
|
||
if w.Code != tc.wantCode {
|
||
t.Errorf("状态码=%d, 期望 %d(body=%s)", w.Code, tc.wantCode, w.Body.String())
|
||
}
|
||
})
|
||
}
|
||
}
|