diff --git a/api/open/v1/open_api.go b/api/open/v1/open_api.go index 247c02f..425c265 100644 --- a/api/open/v1/open_api.go +++ b/api/open/v1/open_api.go @@ -142,6 +142,8 @@ type OpenKeyItem struct { type OpenKeyCreateReq struct { g.Meta `path:"/v1/keys" method:"post" mime:"json" tags:"开放平台API" summary:"创建API Key"` Name string `json:"name" v:"required|length:2,100#请输入Key名称|名称长度2-100" dc:"Key名称"` + ProjectID int64 `json:"project_id" dc:"关联项目 ID(可选;填写后该 Key 随项目归档一并失效)"` + Scope string `json:"scope" d:"full" v:"in:full,chat_only,embeddings_only,images_only,read_only#权限范围无效" dc:"权限范围"` ModelScopes []string `json:"model_scopes" dc:"可用模型列表(为空则不限)"` QuotaLimit float64 `json:"quota_limit" d:"0" dc:"额度上限(0=不限)"` } diff --git a/api/tenant/v1/open_platform.go b/api/tenant/v1/open_platform.go index 2462120..a47394d 100644 --- a/api/tenant/v1/open_platform.go +++ b/api/tenant/v1/open_platform.go @@ -21,6 +21,12 @@ type OpenAppListRes struct { PageSize int `json:"page_size"` } +// OpenAppItem 是应用列表项。 +// +// 注:opn_apps.is_sandbox 列仍在,但整个系统没有任何一处读它,创建/更新接口也从未提供过 +// 入参 —— 它恒为 false,是一个只在界面上挂着「沙箱」徽标、实际拿的却是生产权限的假承诺, +// 故不在此暴露。要让沙箱成立,必须先定义其语义(独立数据域?只读?独立配额?)并在 +// CheckOpenPermission / 各 handler 中真正强制,再恢复该字段与配置入口。 type OpenAppItem struct { ID int64 `json:"id"` Name string `json:"name"` @@ -28,7 +34,6 @@ type OpenAppItem struct { AppID string `json:"app_id"` Permissions []string `json:"permissions"` Status string `json:"status"` - IsSandbox bool `json:"is_sandbox"` RateLimit int `json:"rate_limit"` LastUsedAt string `json:"last_used_at,omitempty"` CreatedAt string `json:"created_at"` diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go index cb4a990..df859d3 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -277,9 +277,13 @@ var ( }) // Open Platform API — HMAC-SHA256 authentication + // 中间件与 /api/tenant 对齐:演示模式同样要拦住写操作(否则演示部署可经开放平台 + // 真实改数据),维护模式头同样要下发,幂等同样要生效 —— 开放平台是对外的、 + // 天然会重试的接口,没有幂等语义比控制台更危险。 + // Idempotency 必须排在 OpenPlatformAuth 之后:它按认证主体隔离幂等键。 s.Group("/api/open", func(group *ghttp.RouterGroup) { group.Middleware(middleware.MiddlewareHandlerResponse) - group.Middleware(middleware.OpenPlatformAuth) + group.Middleware(middleware.DemoMode, middleware.MaintenanceMode, middleware.OpenPlatformAuth, middleware.Idempotency) group.Bind(openController.NewV1()) }) diff --git a/internal/logic/open/open_api.go b/internal/logic/open/open_api.go index bbf5d1a..6745cb1 100644 --- a/internal/logic/open/open_api.go +++ b/internal/logic/open/open_api.go @@ -137,6 +137,15 @@ func (s *sOpen) OpenMemberCreate(ctx context.Context, req *v1.OpenMemberCreateRe return nil, err } + // 团队开关与席位上限:控制台的 InviteMember 两者都校验,开放平台此前一项都没有, + // 于是买 10 个席位的租户可以从这里建出任意多成员,套餐限制被整条绕开。 + if err := requireOpenTeamEnabled(ctx, tenantID); err != nil { + return nil, err + } + if err := requireOpenMemberSeat(ctx, tenantID); err != nil { + return nil, err + } + count, _ := dao.TntUsers.Ctx(ctx).Where("tenant_id", tenantID).Where("email", req.Email).Count() if count > 0 { return nil, errOpen(consts.CodeUsernameExists, "邮箱已存在") @@ -173,17 +182,44 @@ func (s *sOpen) OpenMemberUpdate(ctx context.Context, req *v1.OpenMemberUpdateRe return nil, err } + // 必须先取目标成员的角色:同文件的 OpenMemberDelete 挡了 owner,这里此前没挡。 + // 请求校验 v:"in:admin,member" 只拦住了「把别人提升为 owner」,没拦住「把 owner 降为 + // member」或「把 owner 置为 disabled」—— 而租户 admin 本就能建带 members:write 的应用, + // 于是 admin 可以经由开放平台把组织所有者踢出去,控制台的四处 owner 保护全被绕过。 + var target *struct { + Role string `json:"role"` + } + err := dao.TntUsers.Ctx(ctx). + Where("id", req.Id). + Where("tenant_id", tenantID). + Fields("role"). + Scan(&target) + if err = common.IgnoreScanNoRows(err); err != nil { + return nil, err + } + if target == nil { + return nil, errOpen(consts.CodeNotFound, "用户不存在") + } + data := do.TntUsers{} hasUpdate := false + roleChanged := false if req.Role != nil { + if target.Role == "owner" { + return nil, errOpen(consts.CodeForbidden, "不能修改所有者的角色") + } data.Role = *req.Role hasUpdate = true + roleChanged = *req.Role != target.Role } if req.DisplayName != nil { data.DisplayName = *req.DisplayName hasUpdate = true } if req.Status != nil { + if target.Role == "owner" && *req.Status != "active" { + return nil, errOpen(consts.CodeForbidden, "不能禁用组织所有者") + } data.Status = *req.Status hasUpdate = true } @@ -192,8 +228,19 @@ func (s *sOpen) OpenMemberUpdate(ctx context.Context, req *v1.OpenMemberUpdateRe return nil, nil } - _, err := dao.TntUsers.Ctx(ctx).Where("id", req.Id).Where("tenant_id", tenantID).OmitNil().Data(data).Update() - return nil, err + if _, err = dao.TntUsers.Ctx(ctx).Where("id", req.Id).Where("tenant_id", tenantID).OmitNil().Data(data).Update(); err != nil { + return nil, err + } + + // 与控制台 UpdateMemberRole 对齐:角色变更或停用后撤销该成员的活跃会话。 + // 租户角色本就每请求从库重读,这一步是纵深防御 —— 失败只记日志,不回滚已生效的变更。 + if roleChanged || (req.Status != nil && *req.Status != "active") { + if revokeErr := common.RevokeAllSessions(ctx, "tenant", req.Id); revokeErr != nil { + g.Log().Warningf(ctx, "[OpenMemberUpdate] 撤销成员会话失败 user=%d: %v", req.Id, revokeErr) + } + } + + return nil, nil } func (s *sOpen) OpenMemberDelete(ctx context.Context, req *v1.OpenMemberDeleteReq) (*v1.OpenMemberDeleteRes, error) { @@ -449,6 +496,30 @@ func (s *sOpen) OpenKeyCreate(ctx context.Context, req *v1.OpenKeyCreateReq) (*v return nil, err } + // 权限范围此前被硬编码为 "full" —— 开放平台签发的每一把 Key 都是最大权限, + // 应用方连「只发一把 chat_only 的 Key」都做不到。改为按请求取值(默认仍是 full, + // 保持既有调用方行为不变),取值集合与 OpenProjectKeyCreate 对齐。 + scope := req.Scope + if scope == "" { + scope = "full" + } + + // project_id 可选,填写时必须是本租户下的活跃项目。 + // relay 的项目状态闸门是 `if k.ProjectID > 0`,只有真正写入 project_id,项目归档/停用 + // 才会连带吊销这把 Key;此前这里恒不写 project_id,签出的 Key 完全不受项目生命周期约束。 + // 不填则是租户级集成密钥(无项目、无成员归属),仍按 key_type=project 存储 —— 控制台 + // 的项目密钥列表正是按 key_type 过滤,改成 personal 反而会因缺少 user_id 而在界面上 + // 彻底消失、无法吊销。 + if req.ProjectID > 0 { + project, perr := s.getProjectOrError(ctx, req.ProjectID, tenantID) + if perr != nil { + return nil, perr + } + if project.Status != "active" { + return nil, errOpen(consts.CodeProjectNotActive, "项目状态不可用") + } + } + rawKey, prefix, encryptedKey, err := relay.GenerateApiKey(ctx) if err != nil { return nil, err @@ -460,16 +531,21 @@ func (s *sOpen) OpenKeyCreate(ctx context.Context, req *v1.OpenKeyCreateReq) (*v } quotaLimitDecimal := billing.NewFromFloat(quotaLimit) - result, err := dao.ApiKeys.Ctx(ctx).Data(do.ApiKeys{ + insertData := do.ApiKeys{ TenantId: tenantID, Name: req.Name, EncryptedKey: encryptedKey, KeyPrefix: prefix, - Scope: "full", + Scope: scope, Status: "active", TotalQuota: "aLimitDecimal, KeyType: "project", - }).Insert() + } + if req.ProjectID > 0 { + insertData.ProjectId = req.ProjectID + } + + result, err := dao.ApiKeys.Ctx(ctx).Data(insertData).Insert() if err != nil { return nil, err } @@ -688,6 +764,41 @@ func (s *sOpen) OpenBillingQuery(ctx context.Context, req *v1.OpenBillingQueryRe return &v1.OpenBillingQueryRes{List: items, Total: total, Page: page, PageSize: pageSize}, nil } +// requireOpenTeamEnabled 校验租户已启用团队功能。 +// 与 internal/logic/tenant 的 requireTeamEnabled 同义;那个是包内私有函数, +// 此处不便跨包复用(tenant 包体量大且反向依赖会形成环),故按同一判据本地实现。 +func requireOpenTeamEnabled(ctx context.Context, tenantID int64) error { + enabled, err := dao.TntTenants.Ctx(ctx). + Where("id", tenantID).Fields("team_enabled").Value() + if err != nil { + return err + } + if !enabled.Bool() { + return errOpen(consts.CodeTeamNotEnabled, consts.MsgTeamNotEnabled) + } + return nil +} + +// requireOpenMemberSeat 校验租户仍有可用席位,判据与控制台 InviteMember 完全一致: +// 活跃成员数 < 生效上限(上限为 0 表示不限)。 +func requireOpenMemberSeat(ctx context.Context, tenantID int64) error { + memberCount, err := dao.TntUsers.Ctx(ctx). + Where("tenant_id", tenantID). + Where("status", "active"). + Count() + if err != nil { + return err + } + effectiveMaxMembers, _, err := billing.GetTenantEffectiveLimits(ctx, tenantID) + if err != nil { + return gerror.Wrapf(err, "查询租户限制信息失败") + } + if effectiveMaxMembers > 0 && memberCount >= effectiveMaxMembers { + return errOpen(consts.CodeMemberLimitReached, consts.MsgMemberLimitReached) + } + return nil +} + // errOpen is a helper to create open platform errors with proper gerror codes. func errOpen(code int, message string) error { return gerror.NewCode(gcode.New(code, message, nil), message) diff --git a/internal/logic/tenant/open_platform.go b/internal/logic/tenant/open_platform.go index f406547..71c7ebc 100644 --- a/internal/logic/tenant/open_platform.go +++ b/internal/logic/tenant/open_platform.go @@ -27,6 +27,45 @@ import ( // 开放平台应用管理 // ============================================================ +// openAppPermissionPoints 是开放平台应用可授予的全部权限点。 +// +// 与 internal/logic/open 中各 handler 实际校验的字符串一一对应(models:read 目前尚无 +// handler 使用,保留以免存量授权在编辑时被判为非法)。此前创建/更新只校验 required, +// 任意字符串都能入库:判定是精确匹配且 fail-closed,写错不会越权,但会静默失效 —— +// 写成 members:wirte 的应用在任何接口上都拿不到权限,且排查时毫无线索。 +var openAppPermissionPoints = map[string]bool{ + "members:read": true, + "members:write": true, + "keys:read": true, + "keys:write": true, + "projects:read": true, + "projects:write": true, + "models:read": true, + "usage:read": true, + "billing:read": true, +} + +// sanitizeOpenAppPermissions 校验并规整应用权限点:去重去空、拒绝未定义的权限点。 +func sanitizeOpenAppPermissions(perms []string) ([]string, error) { + seen := make(map[string]bool, len(perms)) + out := make([]string, 0, len(perms)) + for _, p := range perms { + p = strings.TrimSpace(p) + if p == "" || seen[p] { + continue + } + if !openAppPermissionPoints[p] { + return nil, common.NewBadRequestError("无效的权限点:" + p) + } + seen[p] = true + out = append(out, p) + } + if len(out) == 0 { + return nil, common.NewBadRequestError("请至少选择一个权限点") + } + return out, nil +} + type opnAppEncryptedSecretData struct { EncryptedSecret any `json:"encrypted_secret"` } @@ -71,7 +110,6 @@ func (s *sTenant) OpenAppList(ctx context.Context, req *v1.OpenAppListReq) (*v1. AppID: app.AppId, Permissions: perms, Status: app.Status, - IsSandbox: app.IsSandbox, RateLimit: app.RateLimit, } if app.LastUsedAt != nil { @@ -138,7 +176,11 @@ func (s *sTenant) OpenAppCreate(ctx context.Context, req *v1.OpenAppCreateReq) ( return nil, err } - permsJSON, _ := json.Marshal(req.Permissions) + perms, err := sanitizeOpenAppPermissions(req.Permissions) + if err != nil { + return nil, err + } + permsJSON, _ := json.Marshal(perms) ipJSON, _ := json.Marshal(req.IPWhitelist) if req.IPWhitelist == nil { ipJSON = []byte("[]") @@ -203,7 +245,11 @@ func (s *sTenant) OpenAppUpdate(ctx context.Context, req *v1.OpenAppUpdateReq) ( hasUpdate = true } if req.Permissions != nil { - permsJSON, _ := json.Marshal(req.Permissions) + perms, err := sanitizeOpenAppPermissions(req.Permissions) + if err != nil { + return nil, err + } + permsJSON, _ := json.Marshal(perms) data.Permissions = string(permsJSON) hasUpdate = true } diff --git a/internal/logic/tenant/open_platform_test.go b/internal/logic/tenant/open_platform_test.go new file mode 100644 index 0000000..f185287 --- /dev/null +++ b/internal/logic/tenant/open_platform_test.go @@ -0,0 +1,79 @@ +package tenant + +import ( + "os" + "regexp" + "sort" + "testing" +) + +// checkOpenPermissionCall 匹配 open 层 handler 里的 middleware.CheckOpenPermission(ctx, "xxx:yyy")。 +var checkOpenPermissionCall = regexp.MustCompile(`CheckOpenPermission\(ctx,\s*"([^"]+)"\)`) + +// TestOpenAppPermissionWhitelistCoversHandlers 保证「可授予的权限点」覆盖「实际被校验的权限点」。 +// +// 两者分处两个包:白名单在 tenant(配置侧),校验在 open(执行侧)。若有人新增一个 +// CheckOpenPermission("reports:read") 却忘了加进白名单,租户将永远无法把该权限授予应用 —— +// 接口对所有应用恒 403,且因为 sanitizeOpenAppPermissions 会把它判为「无效的权限点」, +// 连手工配置这条退路也没有。这类漂移不会有编译错误,只能靠测试兜住。 +func TestOpenAppPermissionWhitelistCoversHandlers(t *testing.T) { + src, err := os.ReadFile("../open/open_api.go") + if err != nil { + t.Fatalf("读取 open 层源码失败: %v", err) + } + + matches := checkOpenPermissionCall.FindAllStringSubmatch(string(src), -1) + if len(matches) == 0 { + t.Fatal("未在 open 层源码中匹配到任何 CheckOpenPermission 调用,正则可能已失效") + } + + seen := make(map[string]bool) + var missing []string + for _, m := range matches { + perm := m[1] + if seen[perm] { + continue + } + seen[perm] = true + if !openAppPermissionPoints[perm] { + missing = append(missing, perm) + } + } + + if len(missing) > 0 { + sort.Strings(missing) + t.Errorf("以下权限点被 handler 校验但不在 openAppPermissionPoints 白名单中,租户无法授予:%v", missing) + } +} + +func TestSanitizeOpenAppPermissions(t *testing.T) { + t.Run("去重去空并保留合法权限点", func(t *testing.T) { + got, err := sanitizeOpenAppPermissions([]string{"members:read", " ", "members:read", "usage:read"}) + if err != nil { + t.Fatalf("预期通过,实际报错: %v", err) + } + want := []string{"members:read", "usage:read"} + if len(got) != len(want) { + t.Fatalf("预期 %v,实际 %v", want, got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("预期 %v,实际 %v", want, got) + } + } + }) + + t.Run("拒绝未定义的权限点", func(t *testing.T) { + // 拼错一个字母不会有编译错误,但会让该应用在对应接口上永久失效 + if _, err := sanitizeOpenAppPermissions([]string{"members:wirte"}); err == nil { + t.Fatal("预期拒绝拼写错误的权限点,实际通过") + } + }) + + t.Run("拒绝空集合", func(t *testing.T) { + // 零权限的应用调用任何接口都是 403,配置时就该报错而不是留一个静默失效的应用 + if _, err := sanitizeOpenAppPermissions([]string{}); err == nil { + t.Fatal("预期拒绝空权限集合,实际通过") + } + }) +} diff --git a/internal/middleware/idempotency.go b/internal/middleware/idempotency.go index 9a7bc6e..dede4d6 100644 --- a/internal/middleware/idempotency.go +++ b/internal/middleware/idempotency.go @@ -43,6 +43,17 @@ func Idempotency(r *ghttp.Request) { tenantID := r.GetCtxVar(CtxKeyTenantID).Int64() userID := r.GetCtxVar(CtxKeyUserID).Int64() userType := r.GetCtxVar(CtxKeyUserType).String() + + // 开放平台不走 TenantAuth,身份存在 OpenPlatformAuth 注入的 typed context key 里, + // 上面三个 CtxVar 全是零值。若不单独取,所有应用会共用同一个 ":0:0:" 前缀 —— + // 不同租户复用同一个 Idempotency-Key 就会读到彼此缓存的响应体(跨租户数据泄漏)。 + // 主体取「应用」而非「用户」:开放平台请求由应用发起,重试也以应用为单位。 + if appID := GetOpenAppID(r.Context()); appID > 0 { + userType = "open" + tenantID = GetOpenTenantID(r.Context()) + userID = appID + } + idempotencyKey := buildIdempotencyKey(userType, tenantID, userID, clientKey) // Check if this key has been processed before diff --git a/internal/middleware/open_auth.go b/internal/middleware/open_auth.go index 7f55e8b..94fb1f3 100644 --- a/internal/middleware/open_auth.go +++ b/internal/middleware/open_auth.go @@ -38,7 +38,6 @@ const ( ctxKeyOpenAppID openContextKey = "openAppId" ctxKeyOpenTenantID openContextKey = "openTenantId" ctxKeyOpenPermissions openContextKey = "openPermissions" - ctxKeyOpenIsSandbox openContextKey = "openIsSandbox" ) var openAppCache = lcommon.NewCache("open_app", 60*time.Second) @@ -90,6 +89,15 @@ func OpenPlatformAuth(r *ghttp.Request) { return } + // 租户状态校验:应用只是租户的一条独立入口,租户被停服/销户后必须一并失效。 + // 控制台走 ValidateTenantPrincipal、relay 走 ValidateApiKey,两处都会拦下停服租户; + // 开放平台此前是唯一缺口 —— 停服后仍可继续建成员、建 Key、读账单,停服动作形同虚设。 + // 不加缓存:这是一次主键查询,而开放平台是低频管理接口,缓存只会换来「停服延迟生效」的窗口。 + if !isOpenTenantActive(r.Context(), app.TenantId) { + response.Error(r, consts.ErrTenantSuspended) + return + } + // Check IP whitelist if err := checkOpenAppIPWhitelist(r, app); err != nil { response.Error(r, err) @@ -132,12 +140,31 @@ func OpenPlatformAuth(r *ghttp.Request) { ctx = context.WithValue(ctx, ctxKeyOpenAppID, app.Id) ctx = context.WithValue(ctx, ctxKeyOpenTenantID, app.TenantId) ctx = context.WithValue(ctx, ctxKeyOpenPermissions, app.Permissions) - ctx = context.WithValue(ctx, ctxKeyOpenIsSandbox, app.IsSandbox) r.SetCtx(ctx) r.Middleware.Next() } +// isOpenTenantActive 校验应用所属租户处于 active 状态。 +// 查询失败或租户不存在时一律 fail-closed —— 鉴权环节宁可误拒,不可误放。 +func isOpenTenantActive(ctx context.Context, tenantID int64) bool { + if tenantID <= 0 { + return false + } + var tenant *struct { + Status string `json:"status"` + } + err := dao.TntTenants.Ctx(ctx). + Where("id", tenantID). + Fields("status"). + Scan(&tenant) + if err = lcommon.IgnoreScanNoRows(err); err != nil { + g.Log().Warningf(ctx, "[OpenAuth] tenant status lookup failed tenant=%d: %v", tenantID, err) + return false + } + return tenant != nil && tenant.Status == "active" +} + // checkOpenAppNonce enforces replay protection by recording each nonce in Redis. // 用 SET NX 原子写入 nonce:首次出现返回 OK 放行;已存在说明是重放,写 401 并返回 false。 // Redis 故障时 fail-open(与限流一致),仅记告警,因为时间戳窗口本身已把可重放时间 diff --git a/web/tenant/src/views/open/OpenAppsPage.vue b/web/tenant/src/views/open/OpenAppsPage.vue index 4c9564c..eeeaae9 100644 --- a/web/tenant/src/views/open/OpenAppsPage.vue +++ b/web/tenant/src/views/open/OpenAppsPage.vue @@ -14,7 +14,6 @@ interface OpenApp { app_id: string permissions: string[] status: string - is_sandbox: boolean rate_limit: number last_used_at: string created_at: string @@ -209,7 +208,6 @@ function copyToClipboard(text: string) {

{{ app.name }}

- 沙箱 {{ app.status === 'active' ? '已启用' : '已禁用' }}