fix(permissions):修复用户端权限漏洞
This commit is contained in:
1 parent
e703c9a044
commit
644097fa0c
9 files changed
+297
-14
No files matched your search
@@ -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=不限)"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
+5
-1
@@ -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())
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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("预期拒绝空权限集合,实际通过")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(与限流一致),仅记告警,因为时间戳窗口本身已把可重放时间
|
||||
|
||||
@@ -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) {
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<h3 class="text-lg font-semibold text-gray-900">{{ app.name }}</h3>
|
||||
<span v-if="app.is_sandbox" class="badge badge-warning">沙箱</span>
|
||||
<span :class="app.status === 'active' ? 'badge-success' : 'badge-danger'" class="badge">
|
||||
{{ app.status === 'active' ? '已启用' : '已禁用' }}
|
||||
</span>
|
||||
|
||||
Reference in new issue
Block a user