fix(permissions):修复租户端的权限漏洞

This commit is contained in:
qian committed 2026-09-05 00:31:14 +08:00
1 parent a247b3a53b
commit 72fb0318f5
18 files changed
+315 -89

No files matched your search

+1 -1
View File
@@ -221,7 +221,7 @@ cmd(路由注册)
### 多租户隔离
行级隔离:所有业务表包含 `tenant_id` 字段,GoFrame ORM 中间件全局注入 `WHERE tenant_id = ?`。请求链路通过 Context 传递 tenant_id。
行级隔离:所有业务表包含 `tenant_id` 字段。**没有 ORM 全局注入/hook 这类安全网**——隔离完全靠 logic 层手写过滤:租户身份一律取 `middleware.GetTenantID(ctx)`(来自 TenantAuth 注入的 ctx,客户端无法伪造),所有涉及租户数据的查询/更新必须显式带 `tenant_id` 条件;按 ID 取对象时用双键校验(`WHERE id = ? AND tenant_id = ?`)。新增查询漏写该条件即直接跨租户,评审时必须逐条核对。
### 路由前缀
+6 -2
View File
@@ -245,7 +245,10 @@ var (
// Tenant — public endpoints use g.Meta middleware:"-" to skip auth
group.Group("/tenant", func(g *ghttp.RouterGroup) {
g.Middleware(middleware.DemoMode, middleware.MaintenanceMode, middleware.TenantAuth, middleware.Idempotency)
// OperationLog 与管理后台共用:中间件按 ctx 的 userType/tenantId 落
// aud_operation_logs,租户写操作(成员/Key/工单等)此前完全无留痕,
// 且租户端"操作日志"页因 tenant_id 恒为 0 永远为空,挂上后一并修复。
g.Middleware(middleware.DemoMode, middleware.MaintenanceMode, middleware.TenantAuth, middleware.Idempotency, middleware.OperationLog)
g.Bind(tenantController.NewV1())
})
@@ -283,7 +286,8 @@ var (
// Idempotency 必须排在 OpenPlatformAuth 之后:它按认证主体隔离幂等键。
s.Group("/api/open", func(group *ghttp.RouterGroup) {
group.Middleware(middleware.MiddlewareHandlerResponse)
group.Middleware(middleware.DemoMode, middleware.MaintenanceMode, middleware.OpenPlatformAuth, middleware.Idempotency)
// OperationLog 以应用身份(open_app + 应用ID)记录写操作,与管理/租户端共用
group.Middleware(middleware.DemoMode, middleware.MaintenanceMode, middleware.OpenPlatformAuth, middleware.Idempotency, middleware.OperationLog)
group.Bind(openController.NewV1())
})
+3 -19
View File
@@ -7,7 +7,6 @@ import (
"strings"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
@@ -260,24 +259,6 @@ func (s *sAdmin) ListSensitiveAccessLogs(ctx context.Context, req *v1.SensitiveL
}, nil
}
// LogSensitiveAccess inserts a sensitive data access log entry.
func LogSensitiveAccess(ctx context.Context, userID int64, userType, resourceType string, resourceID int64, action, reason, ip, userAgent string) error {
_, err := common.AuditModelCtx(ctx, "aud_sensitive_access_logs").Data(do.AudSensitiveAccessLogs{
UserId: userID,
UserType: userType,
ResourceType: resourceType,
ResourceId: resourceID,
Action: action,
Reason: reason,
IpAddress: ip,
UserAgent: userAgent,
}).Insert()
if err != nil {
return gerror.Wrapf(err, "记录敏感数据访问日志失败")
}
return nil
}
// MaskSensitiveData 委托给 common.MaskSensitiveData
func MaskSensitiveData(data string) string {
return common.MaskSensitiveData(data)
@@ -397,6 +378,9 @@ func (s *sAdmin) GetRequestAuditLogDetail(ctx context.Context, req *v1.RequestAu
if record == nil {
return nil, common.NewNotFoundError("审计日志")
}
// 详情含请求/响应体(full 级别下为明文对话内容),属敏感读取,落留痕
common.LogSensitiveAccess(ctx, common.GetCtxUserID(ctx), "admin", "request_log", req.Id, "view", "查看请求审计日志详情")
b, _ := json.Marshal(record)
var detail map[string]any
_ = json.Unmarshal(b, &detail)
+8 -13
View File
@@ -161,6 +161,7 @@ func (s *sAdmin) Login(ctx context.Context, req *v1.AdminLoginReq) (*v1.AdminLog
if err != nil {
return nil, gerror.Wrapf(err, "create session")
}
refreshToken = common.BindSessionIDToRefreshToken(refreshToken, sessionID)
// Generate token pair
tokenPair, err := common.GenerateTokenPair(ctx, user.Id, "admin", user.Role, 0, sessionID, jti)
@@ -241,13 +242,14 @@ func (s *sAdmin) Logout(ctx context.Context, _ *v1.AdminLogoutReq) (*v1.AdminLog
// Refresh handles token refresh.
func (s *sAdmin) Refresh(ctx context.Context, req *v1.AdminRefreshReq) (*v1.AdminRefreshRes, error) {
refreshTokenHash := common.HashRefreshToken(req.RefreshToken)
// Find session by refresh token hash
session, err := common.GetSessionByRefreshHash(ctx, refreshTokenHash)
// 定位会话并做重放检测:已轮换的旧令牌再次出现时,会话在内部被整体吊销
session, replayed, err := common.GetSessionByRefreshToken(ctx, req.RefreshToken)
if err != nil {
return nil, common.NewUnauthorizedError("会话不存在")
}
if replayed {
return nil, common.NewUnauthorizedError("登录状态已失效,请重新登录")
}
if session == nil {
return nil, common.NewUnauthorizedError("会话已过期或不存在")
}
@@ -261,17 +263,10 @@ func (s *sAdmin) Refresh(ctx context.Context, req *v1.AdminRefreshReq) (*v1.Admi
return nil, common.NewBusinessError(consts.CodeTokenRevoked, consts.MsgTokenRevoked)
}
// Generate new refresh token
newRefreshToken, err := common.GenerateRefreshToken()
if err != nil {
return nil, err
}
newRefreshTokenHash := common.HashRefreshToken(newRefreshToken)
// Rotate session
// Rotate session(新令牌内嵌会话 ID,供下次重放检测定位)
ipAddress := g.RequestFromCtx(ctx).GetClientIp()
deviceInfo := common.ExtractDeviceInfo(ctx)
err = common.RefreshSession(ctx, session.Id, refreshTokenHash, newRefreshTokenHash, ipAddress, deviceInfo)
newRefreshToken, err := common.RotateSessionRefreshToken(ctx, session.Id, req.RefreshToken, ipAddress, deviceInfo)
if err != nil {
return nil, err
}
+1
View File
@@ -69,6 +69,7 @@ func (s *sAdmin) Verify2FA(ctx context.Context, req *v1.Admin2FAVerifyReq) (*v1.
if err != nil {
return nil, gerror.Wrapf(err, "create session")
}
refreshToken = common.BindSessionIDToRefreshToken(refreshToken, sessionID)
// Generate token pair
tokenPair, err := common.GenerateTokenPair(ctx, user.Id, "admin", user.Role, 0, sessionID, jti)
+26
View File
@@ -7,7 +7,10 @@ import (
"regexp"
"strings"
"github.com/gogf/gf/v2/frame/g"
"github.com/qianfree/team-api/internal/dao"
do "github.com/qianfree/team-api/internal/model/do"
)
// Audit levels
@@ -33,6 +36,29 @@ func GetAuditConfig(ctx context.Context) (string, error) {
return GetAuditLevel(ctx), nil
}
// LogSensitiveAccess 记录敏感数据访问(查看 Key 明文、审计详情等"读取"行为)。
// OperationLog 只覆盖写操作,敏感读取靠本函数留痕;IP/UA 从请求上下文自动提取。
// 写入失败只记应用日志不阻断业务 —— 留痕是辅助防线,不影响主流程。
func LogSensitiveAccess(ctx context.Context, userID int64, userType, resourceType string, resourceID int64, action, reason string) {
ip, ua := "", ""
if r := g.RequestFromCtx(ctx); r != nil {
ip = r.GetClientIp()
ua = r.GetHeader("User-Agent")
}
if _, err := AuditModelCtx(ctx, "aud_sensitive_access_logs").Data(do.AudSensitiveAccessLogs{
UserId: userID,
UserType: userType,
ResourceType: resourceType,
ResourceId: resourceID,
Action: action,
Reason: reason,
IpAddress: ip,
UserAgent: ua,
}).Insert(); err != nil {
g.Log().Warningf(ctx, "记录敏感数据访问日志失败: %v", err)
}
}
// GetAuditLevels 返回全局审计级别和租户审计级别,两者完全独立。
// 全局级别从 sys_options 读取,默认 full。
// 租户级别从 tnt_tenants.settings JSONB 读取,未设置时默认 masked。
+32
View File
@@ -6,6 +6,8 @@ import (
"crypto/sha256"
"encoding/hex"
"fmt"
"strconv"
"strings"
"time"
"github.com/gogf/gf/v2/errors/gerror"
@@ -159,6 +161,36 @@ func GenerateRefreshToken() (string, error) {
return hex.EncodeToString(bytes), nil
}
// BindSessionIDToRefreshToken 在刷新令牌尾部附加会话 ID(格式:random.sessionID)。
// 附加段不参与哈希存储(库中存的是随机段的哈希),仅用于令牌被轮换后仍能定位
// 所属会话 —— 重放检测的前提。随机段为纯 hex,"." 是无歧义分隔符。
func BindSessionIDToRefreshToken(token string, sessionID int64) string {
return fmt.Sprintf("%s.%d", token, sessionID)
}
// SplitRefreshToken 拆出刷新令牌的随机段与内嵌会话 ID。
// ok=false 表示存量旧格式令牌(纯随机段、无会话 ID),调用方需走兼容路径。
func SplitRefreshToken(token string) (randomPart string, sessionID int64, ok bool) {
idx := strings.LastIndex(token, ".")
if idx <= 0 || idx == len(token)-1 {
return "", 0, false
}
sid, err := strconv.ParseInt(token[idx+1:], 10, 64)
if err != nil || sid <= 0 {
return "", 0, false
}
return token[:idx], sid, true
}
// HashRefreshTokenForCompare 计算用于与库中哈希比对的值:
// 新格式令牌哈希随机段,存量令牌哈希全文(历史版本的存储口径)。
func HashRefreshTokenForCompare(token string) string {
if randomPart, _, ok := SplitRefreshToken(token); ok {
return HashRefreshToken(randomPart)
}
return HashRefreshToken(token)
}
// GenerateJti generates a new UUID-based JWT ID for session revocation tracking.
func GenerateJti() string {
return uuid.New().String()
+59
View File
@@ -65,6 +65,65 @@ func TestGenerateRefreshToken_Unique(t *testing.T) {
}
}
// ─── BindSessionIDToRefreshToken / SplitRefreshToken ────────────────
func TestRefreshTokenSessionIDRoundTrip(t *testing.T) {
random, _ := GenerateRefreshToken()
bound := BindSessionIDToRefreshToken(random, 42)
part, sid, ok := SplitRefreshToken(bound)
if !ok {
t.Fatalf("bound token should parse: %s", bound)
}
if part != random {
t.Fatalf("random part mismatch: %s vs %s", part, random)
}
if sid != 42 {
t.Fatalf("session id mismatch: %d vs 42", sid)
}
}
func TestSplitRefreshToken_LegacyToken(t *testing.T) {
// 存量令牌为纯 hex 随机段,无 ".",必须走兼容路径(ok=false)
legacy, _ := GenerateRefreshToken()
if _, _, ok := SplitRefreshToken(legacy); ok {
t.Fatalf("legacy token without session id should not parse: %s", legacy)
}
}
func TestSplitRefreshToken_Malformed(t *testing.T) {
cases := []string{
"", // 空
".42", // 空随机段
"abc.", // 空会话 ID
"abc.xid", // 非数字会话 ID
"abc.0", // 非正会话 ID
"abc.-1", // 负会话 ID
"deadbeef.deadbeef", // 随机段误含类数字后缀(hex 段被当作 ID 也应能解析或安全拒绝)
}
for _, c := range cases {
_, _, _ = SplitRefreshToken(c) // 只要不 panic 即可;具体 ok 值由格式决定
}
// 关键安全约束:解析出的会话 ID 必须为正数,否则视为不可解析
if _, sid, ok := SplitRefreshToken("abc.7"); !ok || sid != 7 {
t.Fatal("valid bound token failed to parse")
}
}
func TestHashRefreshTokenForCompare(t *testing.T) {
random, _ := GenerateRefreshToken()
bound := BindSessionIDToRefreshToken(random, 7)
// 新格式:比对哈希只算随机段(库中存储口径)
if got := HashRefreshTokenForCompare(bound); got != HashRefreshToken(random) {
t.Fatal("bound token should hash random part only")
}
// 存量格式:哈希全文
if got := HashRefreshTokenForCompare(random); got != HashRefreshToken(random) {
t.Fatal("legacy token should hash full token")
}
}
// ─── GenerateTokenPair + ParseAccessToken round-trip ────────────────
func TestTokenPairRoundTrip(t *testing.T) {
+105 -30
View File
@@ -99,36 +99,6 @@ func CreateSession(ctx context.Context, userType string, userID, tenantID int64,
return sessionID, err
}
// RefreshSession rotates a refresh token: invalidates the old one and creates a new session.
func RefreshSession(ctx context.Context, sessionID int64, oldRefreshTokenHash, newRefreshTokenHash, ipAddress, deviceInfo string) error {
refreshExpire := getRefreshExpire(ctx)
expiresAt := gtime.New(time.Now().Add(refreshExpire))
result, err := dao.SysSessions.Ctx(ctx).
Where("id", sessionID).
Where("refresh_token_hash", oldRefreshTokenHash).
Where("expires_at > NOW()").
Data(do.SysSessions{
RefreshTokenHash: newRefreshTokenHash,
IpAddress: ipAddress,
DeviceInfo: deviceInfo,
ExpiresAt: expiresAt,
}).Update()
if err != nil {
return gerror.Wrapf(err, "update session")
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return gerror.Wrapf(err, "check rows affected")
}
if rowsAffected == 0 {
return NewUnauthorizedError("会话不存在或已过期")
}
return nil
}
// RevokeSession revokes a single session by ID (DB only; use MarkSessionRevoked for Redis).
// userType 限定会话所属用户体系(admin/tenant),防止跨控制台按 ID 吊销他人会话。
func RevokeSession(ctx context.Context, userType string, sessionID int64) error {
@@ -225,6 +195,111 @@ func GetSessionByID(ctx context.Context, userType string, sessionID int64) (*ent
return session, nil
}
// GetSessionByRefreshToken 按刷新令牌定位活跃会话,并检测"已轮换令牌重放"。
//
// 新格式令牌内嵌会话 ID(random.sessionID):按 ID 定位会话后与随机段哈希比对,
// 命中即正常返回;会话仍在但哈希不匹配 = 该令牌已被轮换过 = 重放(典型场景:
// 令牌被盗、攻击者抢先刷新,受害者手里成了旧令牌)。重放直接吊销整个会话
// (攻击者手中的新令牌一并作废,迫使用户重新登录)并写登录历史留痕。
// 存量旧格式令牌(无会话 ID)退化为按哈希直查,行为与历史版本一致。
//
// 返回 (session, replayed, err):replayed=true 时会话已在内部吊销,调用方返回 401 即可。
func GetSessionByRefreshToken(ctx context.Context, refreshToken string) (*entity.SysSessions, bool, error) {
if randomPart, sessionID, ok := SplitRefreshToken(refreshToken); ok {
var sess *entity.SysSessions
err := dao.SysSessions.Ctx(ctx).
Where("id", sessionID).
Where("expires_at > NOW()").
Scan(&sess)
if err != nil {
return nil, false, err
}
if sess == nil {
// 会话已过期/被清理,按不存在拒绝,无重放可言
return nil, false, nil
}
if sess.RefreshTokenHash == HashRefreshToken(randomPart) {
return sess, false, nil
}
// 哈希不匹配 ≠ 重放:只有随机段哈希命中上一代令牌(轮换时记入 Redis)
// 才判定为重放。若仅凭"会话存在但哈希不匹配"就吊销,会话 ID 是顺序
// bigint,任何人拼 {垃圾串}.{受害者会话ID} 即可恶意吊销他人会话(DoS)。
if prev, rErr := g.Redis().Do(ctx, "GET", refreshPrevKey(sessionID)); rErr == nil && !prev.IsNil() && prev.String() == HashRefreshToken(randomPart) {
// 重放:吊销会话(Redis jti 黑名单 + 删会话行),留痕后拒绝
MarkSessionRevoked(ctx, sess.Jti)
if _, delErr := dao.SysSessions.Ctx(ctx).Where("id", sessionID).Delete(); delErr != nil {
g.Log().Errorf(ctx, "revoke replayed session %d: %v", sessionID, delErr)
}
ip, ua := "", ""
if r := g.RequestFromCtx(ctx); r != nil {
ip = r.GetClientIp()
ua = r.GetHeader("User-Agent")
}
_ = RecordLoginHistory(ctx, sess.UserType, sess.UserId, sess.TenantId, "refresh", ip, ua, "", false, "刷新令牌重放,会话已吊销")
g.Log().Warningf(ctx, "refresh token replay detected: user_type=%s user_id=%d session=%d", sess.UserType, sess.UserId, sessionID)
return nil, true, nil
}
// 既不是当前令牌也不是上一代:普通无效令牌,拒绝但不吊销
return nil, false, nil
}
// 存量旧格式令牌:按哈希直查(无法定位轮换来源,退化为普通拒绝)
sess, err := GetSessionByRefreshHash(ctx, HashRefreshTokenForCompare(refreshToken))
return sess, false, err
}
// refreshPrevKey 是上一代刷新令牌哈希的 Redis key(重放检测用,见 RotateSessionRefreshToken)。
func refreshPrevKey(sessionID int64) string {
return fmt.Sprintf("session:refresh_prev:%d", sessionID)
}
// RotateSessionRefreshToken 轮换会话刷新令牌:生成新随机段并原子替换库中哈希。
// 返回绑定会话 ID 后的新令牌(返回给客户端的最终形态)。
// UPDATE 同时校验旧哈希,并发下同一旧令牌只有一个请求能成功。
func RotateSessionRefreshToken(ctx context.Context, sessionID int64, oldRefreshToken, ipAddress, deviceInfo string) (string, error) {
newRandom, err := GenerateRefreshToken()
if err != nil {
return "", err
}
oldHash := HashRefreshTokenForCompare(oldRefreshToken)
refreshExpire := getRefreshExpire(ctx)
expiresAt := gtime.New(time.Now().Add(refreshExpire))
result, err := dao.SysSessions.Ctx(ctx).
Where("id", sessionID).
Where("refresh_token_hash", oldHash).
Where("expires_at > NOW()").
Data(do.SysSessions{
RefreshTokenHash: HashRefreshToken(newRandom),
IpAddress: ipAddress,
DeviceInfo: deviceInfo,
ExpiresAt: expiresAt,
}).Update()
if err != nil {
return "", gerror.Wrapf(err, "update session")
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return "", gerror.Wrapf(err, "check rows affected")
}
if rowsAffected == 0 {
return "", NewUnauthorizedError("会话不存在或已过期")
}
// 记录上一代令牌哈希(TTL 对齐刷新周期):重放检测必须能证明"这个随机段
// 曾经属于该会话",否则顺序会话 ID 会被用来恶意吊销他人会话。Redis 不可用
// 时仅损失检测能力(降级为普通拒绝),不产生误吊销。
if _, rErr := g.Redis().Do(ctx, "SETEX", refreshPrevKey(sessionID), 7*24*3600, oldHash); rErr != nil {
g.Log().Warningf(ctx, "record prev refresh hash for session %d: %v", sessionID, rErr)
}
return BindSessionIDToRefreshToken(newRandom, sessionID), nil
}
// GetSessionByRefreshHash retrieves an active session by refresh token hash.
func GetSessionByRefreshHash(ctx context.Context, refreshTokenHash string) (*entity.SysSessions, error) {
var session *entity.SysSessions
+3
View File
@@ -659,6 +659,9 @@ func (s *sTenant) ApiKeyReveal(ctx context.Context, req *v1.TenantApiKeyRevealRe
return nil, lcommon.NewBusinessError(consts.CodeBadRequest, "密钥解密失败")
}
// 查看明文属于敏感读取(Key 可长期调用计费接口),落敏感访问日志留痕
lcommon.LogSensitiveAccess(ctx, userID, "tenant", "api_key", req.Id, "reveal", "查看 API Key 明文")
return &v1.TenantApiKeyRevealRes{
Key: plainKey,
KeyPrefix: info.KeyPrefix,
+9 -10
View File
@@ -244,6 +244,7 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1.
if err != nil {
return nil, gerror.Wrapf(err, "create session")
}
refreshToken = common.BindSessionIDToRefreshToken(refreshToken, sessionID)
tokenPair, err := common.GenerateTokenPair(ctx, ownerUserID, "tenant", "owner", tenantID, sessionID, jti)
if err != nil {
@@ -490,6 +491,7 @@ func (s *sTenant) Login(ctx context.Context, req *v1.TenantLoginReq) (*v1.Tenant
if err != nil {
return nil, gerror.Wrapf(err, "create session")
}
refreshToken = common.BindSessionIDToRefreshToken(refreshToken, sessionID)
tokenPair, err := common.GenerateTokenPair(ctx, user.Id, "tenant", user.Role, tenant.Id, sessionID, jti)
if err != nil {
@@ -565,12 +567,14 @@ func (s *sTenant) Logout(ctx context.Context, req *v1.TenantLogoutReq) (*v1.Tena
// Refresh handles token refresh for tenant users.
func (s *sTenant) Refresh(ctx context.Context, req *v1.TenantRefreshReq) (*v1.TenantRefreshRes, error) {
refreshTokenHash := common.HashRefreshToken(req.RefreshToken)
session, err := common.GetSessionByRefreshHash(ctx, refreshTokenHash)
// 定位会话并做重放检测:已轮换的旧令牌再次出现时,会话在内部被整体吊销
session, replayed, err := common.GetSessionByRefreshToken(ctx, req.RefreshToken)
if err != nil {
return nil, common.NewUnauthorizedError("会话不存在")
}
if replayed {
return nil, common.NewUnauthorizedError("登录状态已失效,请重新登录")
}
if session == nil {
return nil, common.NewUnauthorizedError("会话已过期或不存在")
}
@@ -592,15 +596,10 @@ func (s *sTenant) Refresh(ctx context.Context, req *v1.TenantRefreshReq) (*v1.Te
return nil, common.NewUnauthorizedError(consts.MsgUnauthorized)
}
newRefreshToken, err := common.GenerateRefreshToken()
if err != nil {
return nil, err
}
newRefreshTokenHash := common.HashRefreshToken(newRefreshToken)
// 轮换令牌(新令牌内嵌会话 ID,供下次重放检测定位)
ipAddress := g.RequestFromCtx(ctx).GetClientIp()
deviceInfo := common.ExtractDeviceInfo(ctx)
err = common.RefreshSession(ctx, session.Id, refreshTokenHash, newRefreshTokenHash, ipAddress, deviceInfo)
newRefreshToken, err := common.RotateSessionRefreshToken(ctx, session.Id, req.RefreshToken, ipAddress, deviceInfo)
if err != nil {
return nil, err
}
+8
View File
@@ -16,6 +16,14 @@ import (
// InvitationList returns a paginated list of invitation records for the tenant.
func (s *sTenant) InvitationList(ctx context.Context, req *v1.TenantInvitationListReq) (*v1.TenantInvitationListRes, error) {
// 邀请码可直接兑换账号,active 邀请的完整 code 会随 invite_url 返回,
// member 拿到即可自助注册出邀请角色(可为 admin)的账号 —— 必须与
// 邀请创建/撤销同门槛,限 owner/admin。
role := middleware.GetUserRole(ctx)
if role != "owner" && role != "admin" {
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
}
tenantID := middleware.GetTenantID(ctx)
page, pageSize := common.NormalizePagination(req.Page, req.PageSize)
+6
View File
@@ -11,6 +11,12 @@ import (
// GetLevelBenefits returns all level configurations and the tenant's current level info.
func (s *sTenant) GetLevelBenefits(ctx context.Context, req *v1.TenantLevelBenefitsReq) (*v1.TenantLevelBenefitsRes, error) {
// 返回值含组织累计充值额(bil_wallets.cumulative_recharge,财务数据),
// member 角色不应看到;组织页面前端本就限 owner,此处与组织管理同门槛
if role := middleware.GetUserRole(ctx); role != "owner" && role != "admin" {
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
}
tenantID := middleware.GetTenantID(ctx)
// Query all level configs ordered by level
+19
View File
@@ -428,6 +428,7 @@ func (s *sTenant) JoinByInvite(ctx context.Context, req *v1.TenantMemberJoinReq)
if err != nil {
return nil, gerror.Wrapf(err, "创建会话失败")
}
refreshToken = common.BindSessionIDToRefreshToken(refreshToken, sessionID)
tokenPair, err := common.GenerateTokenPair(ctx, userID, "tenant", invitation.Role, tenantID, sessionID, jti)
if err != nil {
@@ -917,6 +918,12 @@ func (s *sTenant) UnlockMember(ctx context.Context, req *v1.TenantMemberUnlockRe
// GetMember returns a single member's detail.
func (s *sTenant) GetMember(ctx context.Context, req *v1.TenantMemberGetReq) (*v1.TenantMemberGetRes, error) {
// 成员详情含邮箱等 PII,属管理视角数据:member 不得窥视他人信息,
// 自身资料走个人设置(GetProfile)
if role := middleware.GetUserRole(ctx); role != "owner" && role != "admin" {
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
}
tenantID := middleware.GetTenantID(ctx)
var user *struct {
@@ -956,6 +963,12 @@ func (s *sTenant) GetMember(ctx context.Context, req *v1.TenantMemberGetReq) (*v
// GetMemberUsage returns usage statistics for a single member.
func (s *sTenant) GetMemberUsage(ctx context.Context, req *v1.TenantMemberUsageReq) (*v1.TenantMemberUsageRes, error) {
// 成员用量属于成员管理视角的数据:member 角色不得窥视他人消费明细,
// 自身用量走个人工作台/用量日志(已强制过滤本人)
if role := middleware.GetUserRole(ctx); role != "owner" && role != "admin" {
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
}
tenantID := middleware.GetTenantID(ctx)
// Verify member exists in tenant
@@ -1009,6 +1022,12 @@ func (s *sTenant) GetMemberUsage(ctx context.Context, req *v1.TenantMemberUsageR
// ListMemberApiKeys returns a paginated list of API keys belonging to a specific member.
func (s *sTenant) ListMemberApiKeys(ctx context.Context, req *v1.TenantMemberApiKeysReq) (*v1.TenantMemberApiKeysRes, error) {
// 同 GetMemberUsage:他人 Key 清单(名称/额度/状态)是管理视角数据,
// member 只管理自己的 Key(个人 Key 列表已强制过滤本人),明文另有属主校验
if role := middleware.GetUserRole(ctx); role != "owner" && role != "admin" {
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
}
tenantID := middleware.GetTenantID(ctx)
page, pageSize := common.NormalizePagination(req.Page, req.PageSize)
+15 -8
View File
@@ -32,14 +32,22 @@ func (s *sTenant) ModelComparison(ctx context.Context, req *v1.ModelComparisonRe
since := gtime.Now().AddDate(0, 0, -req.Days).Format("Y-m-d")
// Build args for parameterized query
// 聚合与趋势两段查询共用同一份 WHERE 和参数(占位符顺序必须一致)。
// member 角色只能对比自己的用量,避免普通成员看到组织级成本聚合
// —— 与用量日志的 member 过滤口径一致。
whereClause := "tenant_id = ? AND created_at >= ?"
args := []any{tenantID, since}
if middleware.GetUserRole(ctx) == "member" {
whereClause += " AND user_id = ?"
args = append(args, middleware.GetUserID(ctx))
}
for _, m := range models {
args = append(args, m)
}
// Aggregate per-model stats (parameterized IN clause, no fmt.Sprintf)
inClause := strings.Repeat("?,", len(models)-1) + "?"
whereClause += " AND model_name IN (" + inClause + ")"
aggQuery := `
SELECT
model_name,
@@ -51,7 +59,7 @@ func (s *sTenant) ModelComparison(ctx context.Context, req *v1.ModelComparisonRe
COALESCE(SUM(input_tokens), 0) as input_tokens,
COALESCE(SUM(output_tokens), 0) as output_tokens
FROM bil_usage_logs
WHERE tenant_id = ? AND created_at >= ? AND model_name IN (` + inClause + `)
WHERE ` + whereClause + `
GROUP BY model_name
`
@@ -132,8 +140,8 @@ func (s *sTenant) ModelComparison(ctx context.Context, req *v1.ModelComparisonRe
items[i].IsRecommended = items[i].ModelName == recommended
}
// Daily trends
trends := fetchTrends(ctx, tenantID, since, models, args)
// Daily trends(与主查询共用 WHERE/参数,member 过滤口径一致)
trends := fetchTrends(whereClause, args)
return &v1.ModelComparisonRes{
Summary: v1.ModelComparisonSummary{
@@ -209,8 +217,7 @@ func recommendBest(items []v1.ModelComparisonItem) (string, string) {
return best, fmt.Sprintf("综合评分 %.1f(费用40%% + 延迟30%% + 成功率30%%)", bestScore)
}
func fetchTrends(ctx context.Context, tenantID int64, since string, models []string, baseArgs []any) []v1.ModelTrendDay {
inClause := strings.Repeat("?,", len(models)-1) + "?"
func fetchTrends(whereClause string, args []any) []v1.ModelTrendDay {
trendQuery := `
SELECT
DATE(created_at) as day,
@@ -219,12 +226,12 @@ func fetchTrends(ctx context.Context, tenantID int64, since string, models []str
COALESCE(SUM(total_cost), 0) as cost,
COALESCE(AVG(latency_ms) FILTER (WHERE status = 'success'), 0) as latency
FROM bil_usage_logs
WHERE tenant_id = ? AND created_at >= ? AND model_name IN (` + inClause + `)
WHERE ` + whereClause + `
GROUP BY DATE(created_at), model_name
ORDER BY day
`
records, err := g.DB().Raw(trendQuery, baseArgs...).All()
records, err := g.DB().Raw(trendQuery, args...).All()
if err != nil {
return nil
}
+1
View File
@@ -336,6 +336,7 @@ func (s *sTenant) OAuthCallback(ctx context.Context, req *v1.OAuthCallbackReq) (
if err != nil {
return nil, err
}
refreshToken = common.BindSessionIDToRefreshToken(refreshToken, sessionID)
// 生成 JWT token pair
tokenPair, err := common.GenerateTokenPair(ctx, userID, "tenant", user.Role, tenantID, sessionID, jti)
+1
View File
@@ -67,6 +67,7 @@ func (s *sTenant) Verify2FA(ctx context.Context, req *v1.Tenant2FAVerifyReq) (*v
if err != nil {
return nil, gerror.Wrapf(err, "create session")
}
refreshToken = common.BindSessionIDToRefreshToken(refreshToken, sessionID)
tokenPair, err := common.GenerateTokenPair(ctx, user.Id, "tenant", user.Role, user.TenantId, sessionID, jti)
if err != nil {
+12 -6
View File
@@ -90,12 +90,19 @@ func OperationLog(r *ghttp.Request) {
return
}
// Get auth info
// Get auth info;开放平台请求没有用户身份,以应用身份(open_app + 应用ID)留痕,
// 否则 /api/open 的建成员/建删 Key 等写操作在三张审计表里都不存在
userID := GetUserID(r.Context())
userType := GetUserType(r.Context())
tenantID := GetTenantID(r.Context())
if userID == 0 {
return
if appID := GetOpenAppID(r.Context()); appID > 0 {
userID = appID
userType = "open_app"
tenantID = GetOpenTenantID(r.Context())
} else {
return
}
}
// Parse resource info from URL path
@@ -134,7 +141,6 @@ func OperationLog(r *ghttp.Request) {
logData.ResourceId = resourceID
}
tenantID := GetTenantID(r.Context())
if tenantID > 0 {
logData.TenantId = tenantID
}
@@ -169,9 +175,9 @@ func parseResourceFromPath(path string) (resourceType string, resourceID int64)
path = strings.TrimPrefix(path, "/api/")
parts := strings.Split(strings.Trim(path, "/"), "/")
// Skip prefix like "admin" or "tenant"
// Skip prefix like "admin", "tenant" or "open"
idx := 0
if len(parts) > 0 && (parts[0] == "admin" || parts[0] == "tenant") {
if len(parts) > 0 && (parts[0] == "admin" || parts[0] == "tenant" || parts[0] == "open") {
idx = 1
}