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>
59 lines
2.0 KiB
Go
59 lines
2.0 KiB
Go
package middleware
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"github.com/sundynix/sundynix-gateway/internal/store"
|
|
)
|
|
|
|
// CtxSpaceID 是当前活跃工作区(Space) id 在 gin.Context 的键(增量3 共享工作区)。
|
|
const CtxSpaceID = "space_id"
|
|
|
|
// SpaceContext 解析当前用户在活跃租户内的活跃工作区并注入 space_id。
|
|
// 须挂在 Auth + TenantContext 之后(依赖 uid 与 tenant_id);未登录/无租户则跳过。
|
|
// 活跃空间无效(跨租户/已归档/非成员)时由 ActiveSpaceForUser 兜底回退个人空间。
|
|
func SpaceContext(db *store.Postgres) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
uid, _ := c.Get(CtxUserID)
|
|
id, _ := uid.(string)
|
|
tid, _ := c.Get(CtxTenantID)
|
|
tenant, _ := tid.(string)
|
|
if id != "" && tenant != "" {
|
|
if s, err := db.ActiveSpaceForUser(c.Request.Context(), tenant, id); err == nil && s != nil {
|
|
c.Set(CtxSpaceID, s.ID)
|
|
}
|
|
}
|
|
c.Next()
|
|
}
|
|
}
|
|
|
|
// SpaceRoleResolver 解析用户在某空间的角色(*store.Postgres 天然满足)。抽接口便于单测门控。
|
|
type SpaceRoleResolver interface {
|
|
SpaceMemberRole(ctx context.Context, spaceID, userID string) string
|
|
}
|
|
|
|
// RequireSpaceRole 要求当前用户在活跃空间中的角色不低于 minRole(按 store.RoleRank 阶梯)。
|
|
// 须挂在 RequireAuth + SpaceContext 之后。非成员/无空间 → rank 0 → 拦下。
|
|
// 用于共享工作区的写操作门控(建/存/删编排要 ≥member;viewer 只读)。
|
|
func RequireSpaceRole(db SpaceRoleResolver, minRole string) gin.HandlerFunc {
|
|
min := store.RoleRank(minRole)
|
|
return func(c *gin.Context) {
|
|
uid, _ := c.Get(CtxUserID)
|
|
id, _ := uid.(string)
|
|
if id == "" {
|
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "需要登录"})
|
|
return
|
|
}
|
|
sid, _ := c.Get(CtxSpaceID)
|
|
space, _ := sid.(string)
|
|
if store.RoleRank(db.SpaceMemberRole(c.Request.Context(), space, id)) < min {
|
|
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "当前空间角色为只读,无权执行该操作"})
|
|
return
|
|
}
|
|
c.Next()
|
|
}
|
|
}
|