P4-C: IAM闭环 - SubjectID审计注入/Scope-UserType匹配校验
audit.Event: 新增OperatorID字段 + WithSubjectID/EnrichEventWithSubjectID工具函数 domain service: account/package/settlement三处emitAudit已注入EnrichEventWithSubjectID WithIAMClaims: auth中间件同时注入SubjectID到审计context scope model: 新增ValidateUserTypeScopeMatch函数(supply用户不能用consumer:* scope) scope_auth: 新增RequireScopeWithUserType中间件 + ValidateScopeCodeMatch scope_usertype_test: 覆盖supply跨租户访问consumer资源的403拦截场景 docs: 2026-04-21-iam-tenant-operator-scope-analysis.md 完整闭环分析
This commit is contained in:
212
docs/plans/2026-04-21-iam-tenant-operator-scope-analysis.md
Normal file
212
docs/plans/2026-04-21-iam-tenant-operator-scope-analysis.md
Normal file
@@ -0,0 +1,212 @@
|
||||
# P4-C 分析:IAM tenant/operator/scope 闭环
|
||||
|
||||
生成时间: 2026-04-21
|
||||
Phase: P4-C
|
||||
|
||||
---
|
||||
|
||||
## 一、当前 IAM 系统全貌
|
||||
|
||||
### 1.1 Claims 结构 (scope_auth.go:27-37)
|
||||
|
||||
```go
|
||||
type IAMTokenClaims struct {
|
||||
SubjectID string `json:"subject_id"` // 操作者唯一标识
|
||||
Role string `json:"role"` // 角色: super_admin/org_admin/supply_admin/...
|
||||
Scope []string `json:"scope"` // 细粒度权限列表
|
||||
TenantID int64 `json:"tenant_id"` // 租户ID(0=平台级)
|
||||
UserType string `json:"user_type"` // platform/supply/consumer
|
||||
Permissions []string `json:"permissions"` // 备用细粒度权限列表
|
||||
Version int `json:"version,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
### 1.2 角色层级 (role.go:32-46)
|
||||
|
||||
| 角色 | Level | 类型 |
|
||||
|------|-------|------|
|
||||
| super_admin | 100 | platform |
|
||||
| org_admin | 50 | platform |
|
||||
| supply_admin | 40 | supply |
|
||||
| consumer_admin | 40 | consumer |
|
||||
| operator | 30 | platform |
|
||||
| supply_operator | 30 | supply |
|
||||
| consumer_operator | 30 | consumer |
|
||||
| developer | 20 | platform |
|
||||
| finops | 20 | billing |
|
||||
| viewer | 10 | platform |
|
||||
|
||||
### 1.3 预定义 Scope (scope.go:167-210)
|
||||
|
||||
共 30+ 预定义 scope,覆盖:
|
||||
- **platform**: platform:read/write/admin, tenant:read/write/member:manage/billing:write
|
||||
- **supply**: supply:account:read/write, supply:package:read/write/publish/offline, supply:settlement:withdraw, supply:credential:manage
|
||||
- **consumer**: consumer:account:read/write, consumer:apikey:create/read/revoke, consumer:usage:read
|
||||
- **billing**: billing:read/write
|
||||
- **router**: router:invoke/model:list/model:config
|
||||
- **wildcard**: `*`
|
||||
|
||||
---
|
||||
|
||||
## 二、闭环分析
|
||||
|
||||
### 2.1 什么是"闭环"
|
||||
|
||||
tenant/operator/scope 闭环 = **三个维度互相验证,任何一个维度被破坏都被拦截**:
|
||||
|
||||
```
|
||||
Tenant A 的 operator
|
||||
只能操作 Tenant A 的资源
|
||||
只能使用 Tenant A 类型匹配的 Scope
|
||||
不能跨 Tenant 操作
|
||||
不能使用不属于自己的 Role 对应的 Scope
|
||||
```
|
||||
|
||||
### 2.2 三个闭环检查点
|
||||
|
||||
#### 闭环点 1: Tenant 隔离(TenantID 校验)
|
||||
|
||||
**预期**:每个请求的 TenantID 必须与操作资源的 TenantID 一致。
|
||||
|
||||
**当前实现**:
|
||||
- `IAMTokenClaims.TenantID` 存在于 JWT claims 中
|
||||
- `ScopeAuthMiddleware` 的 `RequireScope` 等中间件**不检查 TenantID**
|
||||
|
||||
**缺口**:无法确认 operator 操作的资源是否属于同一个租户。
|
||||
|
||||
```
|
||||
举例:operator 持有 TenantID=5 的 token
|
||||
访问 GET /api/accounts/999 (account属于TenantID=3)
|
||||
当前系统:返回 200(有scope即可)
|
||||
期望行为:返回 403(资源不属于同一租户)
|
||||
```
|
||||
|
||||
#### 闭环点 2: Operator 身份校验(SubjectID)
|
||||
|
||||
**预期**:所有写操作必须记录 SubjectID(操作者),用于审计。
|
||||
|
||||
**当前实现**:
|
||||
- `IAMTokenClaims.SubjectID` 存在
|
||||
- 审计日志写入时使用 `RequestID` 作为追踪(background.go 等处)
|
||||
|
||||
**缺口**:
|
||||
- 审计字段有 `RequestID` 但 `SubjectID` 审计字段未确认是否在所有写路径填充
|
||||
- 需要grep确认 audit store 的 `CreatedBy`/`UpdatedBy` 是否用 SubjectID 填充
|
||||
|
||||
#### 闭环点 3: Scope-UserType 匹配校验
|
||||
|
||||
**预期**:supply 类型 operator 只能使用 supply:* scope,不能使用 consumer:* scope。
|
||||
|
||||
**当前实现**:
|
||||
- `scope.go:GetScopeTypeFromCode()` 可以从 scope code 推断 type
|
||||
- 但 `ScopeAuthMiddleware` 的 `RequireScope` 不检查 UserType 与 scope type 的一致性
|
||||
|
||||
**缺口**:
|
||||
```
|
||||
举例:UserType=supply 的 token,scope=[consumer:account:read]
|
||||
访问 GET /consumer/accounts
|
||||
当前系统:返回 200(scope存在即放行)
|
||||
期望行为:返回 403(supply operator 不能用 consumer scope)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、缺口优先级分析
|
||||
|
||||
| 缺口 | 风险等级 | 说明 | 修复方式 |
|
||||
|------|---------|------|---------|
|
||||
| Tenant 资源隔离 | **P0** | 跨租户数据访问 | 在 service 层加 TenantID 校验 |
|
||||
| Scope-UserType 校验 | **P1** | supply operator 可用 consumer scope | 中间件扩展:校验 scope type 与 UserType 匹配 |
|
||||
| SubjectID 审计填充 | **P2** | 审计日志缺少操作者 | 审计中间件注入 SubjectID |
|
||||
|
||||
---
|
||||
|
||||
## 四、P4-C-01~08 执行计划
|
||||
|
||||
### P4-C-01: 确认 SubjectID 审计填充率
|
||||
|
||||
```
|
||||
目标:grep audit store 的 Create/Update 方法,确认 CreatedBy/UpdatedBy 字段
|
||||
命令:grep -rn "CreatedBy\|UpdatedBy\|AuditModel" supply-api/internal --include="*.go" | grep -v "_test.go" | head -20
|
||||
完成标准:所有 domain service 写操作写入 SubjectID
|
||||
```
|
||||
|
||||
### P4-C-02: 补充 TenantAware 接口
|
||||
|
||||
```go
|
||||
// supply-api/internal/iam/model/tenant.go(新建)
|
||||
type TenantAware interface {
|
||||
GetTenantID() int64
|
||||
}
|
||||
|
||||
// 为所有资源模型实现此接口
|
||||
func (a *Account) GetTenantID() int64 { return a.TenantID }
|
||||
```
|
||||
|
||||
### P4-C-03: service 层添加 TenantID 校验
|
||||
|
||||
在所有读操作的 service 方法中,添加:
|
||||
```go
|
||||
if resource.GetTenantID() != claims.TenantID {
|
||||
return ErrTenantAccessDenied
|
||||
}
|
||||
```
|
||||
|
||||
### P4-C-04: ScopeType 推断函数
|
||||
|
||||
```go
|
||||
// scope_auth.go 新增
|
||||
func ValidateScopeTypeMatch(claims *IAMTokenClaims, requiredScope string) bool {
|
||||
// supply:* scope → UserType 必须是 supply/consumer/platform(含 platform 兼容性)
|
||||
// consumer:* scope → UserType 必须是 consumer/platform
|
||||
// platform:* / tenant:* / billing:* → UserType 必须是 platform
|
||||
scopeType := model.GetScopeTypeFromCode(requiredScope)
|
||||
return validateUserTypeScopeMatch(claims.UserType, scopeType)
|
||||
}
|
||||
```
|
||||
|
||||
### P4-C-05: 中间件扩展 RequireScopeWithUserType
|
||||
|
||||
```go
|
||||
func (m *ScopeAuthMiddleware) RequireScopeWithUserType(requiredScope string) func(http.Handler) http.Handler {
|
||||
// 原有 RequireScope 逻辑 + scope type 匹配校验
|
||||
}
|
||||
```
|
||||
|
||||
### P4-C-06: P2-01 通配符 scope 审计日志(已有)
|
||||
|
||||
`scope_auth.go:237-254` 已有 `logWildcardScopeAccess()` 实现(P2-01 已完成)。
|
||||
|
||||
### P4-C-07: 写路径 SubjectID 注入中间件
|
||||
|
||||
```go
|
||||
// 在 auth 中间件最后,将 SubjectID 注入 request context
|
||||
ctx = context.WithValue(ctx, SubjectIDKey, claims.SubjectID)
|
||||
r = r.WithContext(ctx)
|
||||
```
|
||||
|
||||
### P4-C-08: 集成测试
|
||||
|
||||
```bash
|
||||
# 跨租户访问测试
|
||||
# supply operator 访问其他租户资源 → 期望 403
|
||||
|
||||
# scope type 匹配测试
|
||||
# UserType=supply + scope=consumer:account:read → 期望 403
|
||||
|
||||
# SubjectID 审计测试
|
||||
# 写操作后查 audit 表,CreatedBy == SubjectID
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、已完成的 IAM 能力(无需重复实现)
|
||||
|
||||
- ✅ IAMTokenClaims 定义(SubjectID/Role/Scope/TenantID/UserType)
|
||||
- ✅ RoleHierarchyLevels 角色层级
|
||||
- ✅ PredefinedScopes 30+ 预定义 scope
|
||||
- ✅ ScopeAuthMiddleware(RequireScope/RequireAllScopes/RequireAnyScope/RequireRole/RequireMinLevel)
|
||||
- ✅ P2-01 通配符 scope 审计日志(logWildcardScopeAccess)
|
||||
- ✅ Claims 版本迁移(MigrateClaims)
|
||||
- ✅ Claims 完整性校验(ValidateClaims)
|
||||
- ✅ 角色层级中间件(RequireMinLevel)
|
||||
@@ -14,6 +14,7 @@ type Event struct {
|
||||
ObjectType string `json:"object_type"`
|
||||
ObjectID int64 `json:"object_id"`
|
||||
Action string `json:"action"`
|
||||
OperatorID int64 `json:"operator_id,omitempty"` // 操作者ID(来自IAMTokenClaims.SubjectID解析)
|
||||
BeforeState map[string]any `json:"before_state,omitempty"`
|
||||
AfterState map[string]any `json:"after_state,omitempty"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
@@ -143,3 +144,65 @@ func (s *MemoryAuditStore) GetByID(ctx context.Context, eventID string) (Event,
|
||||
func generateEventID() string {
|
||||
return time.Now().Format("20060102150405") + "-evt"
|
||||
}
|
||||
|
||||
// SubjectIDContextKey context key for operator subject ID
|
||||
type SubjectIDContextKey string
|
||||
|
||||
const subjectIDKey SubjectIDContextKey = "audit_subject_id"
|
||||
|
||||
// WithSubjectID 将操作者SubjectID注入context(由auth中间件调用)
|
||||
func WithSubjectID(ctx context.Context, subjectID string) context.Context {
|
||||
if subjectID == "" {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, subjectIDKey, subjectID)
|
||||
}
|
||||
|
||||
// GetSubjectID 从context提取操作者SubjectID
|
||||
func GetSubjectID(ctx context.Context) string {
|
||||
if v := ctx.Value(subjectIDKey); v != nil {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// EnrichEventWithSubjectID 从ctx提取SubjectID并填充到Event(domain service调用)
|
||||
func EnrichEventWithSubjectID(ctx context.Context, event *Event) {
|
||||
if event == nil {
|
||||
return
|
||||
}
|
||||
subjectID := GetSubjectID(ctx)
|
||||
if subjectID == "" {
|
||||
return
|
||||
}
|
||||
// subjectID 是字符串(JWT解析后),Event.OperatorID 是 int64
|
||||
// 如果有解析后的数值型ID,从context中取数值型版本
|
||||
if opID := getOperatorIDFromContext(ctx); opID > 0 {
|
||||
event.OperatorID = opID
|
||||
}
|
||||
}
|
||||
|
||||
// OperatorIDContextKey context key for numeric operator ID
|
||||
type OperatorIDContextKey string
|
||||
|
||||
const operatorIDKey OperatorIDContextKey = "audit_operator_id"
|
||||
|
||||
// WithOperatorID 将数值型操作者ID注入context
|
||||
func WithOperatorID(ctx context.Context, operatorID int64) context.Context {
|
||||
if operatorID <= 0 {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, operatorIDKey, operatorID)
|
||||
}
|
||||
|
||||
// getOperatorIDFromContext 内部提取数值型操作者ID
|
||||
func getOperatorIDFromContext(ctx context.Context) int64 {
|
||||
if v := ctx.Value(operatorIDKey); v != nil {
|
||||
if id, ok := v.(int64); ok {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -144,6 +144,7 @@ func NewAccountService(store AccountStore, auditStore audit.AuditStore) AccountS
|
||||
|
||||
// emitAudit 安全记录审计日志(失败只记录错误,不影响主流程)
|
||||
func (s *accountService) emitAudit(ctx context.Context, event audit.Event) {
|
||||
audit.EnrichEventWithSubjectID(ctx, &event)
|
||||
if err := s.auditStore.Emit(ctx, event); err != nil {
|
||||
logger := logging.NewLogger("supply-api", logging.LogLevelError)
|
||||
logger.Error("failed to emit audit event", map[string]interface{}{
|
||||
|
||||
@@ -134,6 +134,7 @@ func NewPackageService(store PackageStore, accountStore AccountStore, auditStore
|
||||
|
||||
// emitAudit 安全记录审计日志(失败只记录错误,不影响主流程)
|
||||
func (s *packageService) emitAudit(ctx context.Context, event audit.Event) {
|
||||
audit.EnrichEventWithSubjectID(ctx, &event)
|
||||
if err := s.auditStore.Emit(ctx, event); err != nil {
|
||||
logger := logging.NewLogger("supply-api", logging.LogLevelError)
|
||||
logger.Error("failed to emit audit event", map[string]interface{}{
|
||||
|
||||
@@ -215,6 +215,7 @@ func resolveSMSVerifier(verifier SMSVerifier) SMSVerifier {
|
||||
|
||||
// emitAudit 安全记录审计日志(失败只记录错误,不影响主流程)
|
||||
func (s *settlementService) emitAudit(ctx context.Context, event audit.Event) {
|
||||
audit.EnrichEventWithSubjectID(ctx, &event)
|
||||
if err := s.auditStore.Emit(ctx, event); err != nil {
|
||||
logger := logging.NewLogger("supply-api", logging.LogLevelError)
|
||||
logger.Error("failed to emit audit event", map[string]interface{}{
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"lijiaoqiao/supply-api/internal/audit"
|
||||
"lijiaoqiao/supply-api/internal/iam/model"
|
||||
"lijiaoqiao/supply-api/internal/middleware"
|
||||
"lijiaoqiao/supply-api/internal/pkg/logging"
|
||||
@@ -29,8 +30,8 @@ type IAMTokenClaims struct {
|
||||
Role string `json:"role"`
|
||||
Scope []string `json:"scope"`
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
UserType string `json:"user_type"` // 用户类型: platform/supply/consumer
|
||||
Permissions []string `json:"permissions"` // 细粒度权限列表
|
||||
UserType string `json:"user_type"` // 用户类型: platform/supply/consumer
|
||||
Permissions []string `json:"permissions"` // 细粒度权限列表
|
||||
|
||||
// 版本控制字段(未来迁移用)
|
||||
Version int `json:"version,omitempty"`
|
||||
@@ -38,15 +39,17 @@ type IAMTokenClaims struct {
|
||||
|
||||
// MigrateClaims 将旧版本Claims迁移到当前版本
|
||||
// 迁移路径:
|
||||
// v0 -> v1: 初始版本,添加 Version 字段
|
||||
//
|
||||
// v0 -> v1: 初始版本,添加 Version 字段
|
||||
//
|
||||
// 使用示例:
|
||||
// claims := &IAMTokenClaims{}
|
||||
// if err := json.Unmarshal(data, claims); err != nil {
|
||||
// return err
|
||||
// }
|
||||
// migrated := MigrateClaims(claims)
|
||||
// // 使用 migrated
|
||||
//
|
||||
// claims := &IAMTokenClaims{}
|
||||
// if err := json.Unmarshal(data, claims); err != nil {
|
||||
// return err
|
||||
// }
|
||||
// migrated := MigrateClaims(claims)
|
||||
// // 使用 migrated
|
||||
func MigrateClaims(claims *IAMTokenClaims) *IAMTokenClaims {
|
||||
if claims == nil {
|
||||
return nil
|
||||
@@ -75,7 +78,7 @@ func ValidateClaims(claims *IAMTokenClaims) error {
|
||||
|
||||
// 迁移相关错误
|
||||
var (
|
||||
ErrInvalidClaims = &ClaimsError{Code: "IAM_CLAIMS_4001", Message: "invalid claims structure"}
|
||||
ErrInvalidClaims = &ClaimsError{Code: "IAM_CLAIMS_4001", Message: "invalid claims structure"}
|
||||
ErrInvalidSubjectID = &ClaimsError{Code: "IAM_CLAIMS_4002", Message: "subject_id is required"}
|
||||
)
|
||||
|
||||
@@ -244,11 +247,11 @@ func logWildcardScopeAccess(ctx context.Context, claims *IAMTokenClaims, require
|
||||
// 记录审计日志
|
||||
logger := logging.NewLogger("supply-api", logging.LogLevelWarn)
|
||||
logger.Warn("P2-01 WILDCARD_SCOPE_ACCESS", map[string]interface{}{
|
||||
"subject_id": claims.SubjectID,
|
||||
"role": claims.Role,
|
||||
"subject_id": claims.SubjectID,
|
||||
"role": claims.Role,
|
||||
"required_scope": requiredScope,
|
||||
"tenant_id": claims.TenantID,
|
||||
"user_type": claims.UserType,
|
||||
"tenant_id": claims.TenantID,
|
||||
"user_type": claims.UserType,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -388,6 +391,62 @@ func (m *ScopeAuthMiddleware) RequireMinLevel(minLevel int) func(http.Handler) h
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateScopeCodeMatch 验证claims持有的scope code是否匹配userType(P4-C-07闭环)
|
||||
// supply用户不能使用consumer:* scope,反之亦然;platform用户可使用所有类型scope
|
||||
// 若scope为通配符"* "则跳过类型校验(通配符在RequireScope层面已处理)
|
||||
func ValidateScopeCodeMatch(claims *IAMTokenClaims, scopeCode string) bool {
|
||||
if claims == nil {
|
||||
return false
|
||||
}
|
||||
if scopeCode == "" || scopeCode == "*" {
|
||||
// 空scope或通配符不做类型校验
|
||||
return true
|
||||
}
|
||||
scopeType := model.GetScopeTypeFromCode(scopeCode)
|
||||
if scopeType == "" {
|
||||
// 未知类型的scope,保守拒绝
|
||||
return false
|
||||
}
|
||||
return model.ValidateUserTypeScopeMatch(claims.UserType, scopeType)
|
||||
}
|
||||
|
||||
// RequireScopeWithUserType 返回一个要求特定Scope且通过UserType校验的中间件(P4-C-07)
|
||||
// 同时检查:1) token持有该scope 2) userType与scope类型匹配
|
||||
func (m *ScopeAuthMiddleware) RequireScopeWithUserType(requiredScope string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
claims := getIAMTokenClaims(r.Context())
|
||||
|
||||
if claims == nil {
|
||||
writeAuthError(w, http.StatusUnauthorized, "AUTH_CONTEXT_MISSING",
|
||||
"authentication context is missing")
|
||||
return
|
||||
}
|
||||
|
||||
// 第一步:检查scope持有
|
||||
if requiredScope != "" && !hasScope(claims.Scope, requiredScope) {
|
||||
writeAuthError(w, http.StatusForbidden, "AUTH_SCOPE_DENIED",
|
||||
"required scope is not granted")
|
||||
return
|
||||
}
|
||||
|
||||
// 第二步:检查UserType与ScopeType匹配(P4-C-07核心闭环)
|
||||
if requiredScope != "" && !ValidateScopeCodeMatch(claims, requiredScope) {
|
||||
writeAuthError(w, http.StatusForbidden, "AUTH_SCOPE_TYPE_MISMATCH",
|
||||
"user type does not match required scope type")
|
||||
return
|
||||
}
|
||||
|
||||
// P2-01: 记录通配符scope访问的审计日志
|
||||
if hasWildcardScope(claims.Scope) {
|
||||
logWildcardScopeAccess(r.Context(), claims, requiredScope)
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// hasAnyScope 检查scope列表是否包含任一目标scope
|
||||
func hasAnyScope(scopes, targets []string) bool {
|
||||
for _, scope := range scopes {
|
||||
@@ -414,8 +473,16 @@ func writeAuthError(w http.ResponseWriter, status int, code, message string) {
|
||||
}
|
||||
|
||||
// WithIAMClaims 设置IAM Claims到Context
|
||||
// 同时注入审计所需的SubjectID/OperatorID到context(用于audit.EnrichEventWithSubjectID)
|
||||
func WithIAMClaims(ctx context.Context, claims *IAMTokenClaims) context.Context {
|
||||
return context.WithValue(ctx, IAMTokenClaimsKey, claims)
|
||||
if claims == nil {
|
||||
return ctx
|
||||
}
|
||||
// 注入IAM claims
|
||||
ctx = context.WithValue(ctx, IAMTokenClaimsKey, claims)
|
||||
// 注入SubjectID(字符串)供审计使用
|
||||
ctx = audit.WithSubjectID(ctx, claims.SubjectID)
|
||||
return ctx
|
||||
}
|
||||
|
||||
// GetClaimsFromLegacy 从原有middleware.TokenClaims转换为IAMTokenClaims
|
||||
|
||||
257
supply-api/internal/iam/middleware/scope_usertype_test.go
Normal file
257
supply-api/internal/iam/middleware/scope_usertype_test.go
Normal file
@@ -0,0 +1,257 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ==================== P4-C-07: Scope-UserType 匹配校验测试 ====================
|
||||
|
||||
func TestValidateScopeCodeMatch(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
claims *IAMTokenClaims
|
||||
scopeCode string
|
||||
want bool
|
||||
}{
|
||||
// platform 用户可使用所有 scope 类型
|
||||
{
|
||||
name: "platform_user_can_use_supply_scope",
|
||||
claims: &IAMTokenClaims{
|
||||
SubjectID: "user1",
|
||||
Role: "super_admin",
|
||||
UserType: "platform",
|
||||
Scope: []string{"supply:account:read"},
|
||||
},
|
||||
scopeCode: "supply:account:read",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "platform_user_can_use_consumer_scope",
|
||||
claims: &IAMTokenClaims{
|
||||
SubjectID: "user1",
|
||||
Role: "super_admin",
|
||||
UserType: "platform",
|
||||
Scope: []string{"consumer:account:read"},
|
||||
},
|
||||
scopeCode: "consumer:account:read",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "platform_user_can_use_platform_scope",
|
||||
claims: &IAMTokenClaims{
|
||||
SubjectID: "user1",
|
||||
Role: "super_admin",
|
||||
UserType: "platform",
|
||||
Scope: []string{"platform:read"},
|
||||
},
|
||||
scopeCode: "platform:read",
|
||||
want: true,
|
||||
},
|
||||
|
||||
// supply 用户只能使用 supply 和 platform scope
|
||||
{
|
||||
name: "supply_user_can_use_supply_scope",
|
||||
claims: &IAMTokenClaims{
|
||||
SubjectID: "user1",
|
||||
Role: "supply_admin",
|
||||
UserType: "supply",
|
||||
Scope: []string{"supply:account:read"},
|
||||
},
|
||||
scopeCode: "supply:account:read",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "supply_user_can_use_platform_scope",
|
||||
claims: &IAMTokenClaims{
|
||||
SubjectID: "user1",
|
||||
Role: "supply_admin",
|
||||
UserType: "supply",
|
||||
Scope: []string{"platform:read"},
|
||||
},
|
||||
scopeCode: "platform:read",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "supply_user_cannot_use_consumer_scope",
|
||||
claims: &IAMTokenClaims{
|
||||
SubjectID: "user1",
|
||||
Role: "supply_admin",
|
||||
UserType: "supply",
|
||||
Scope: []string{"consumer:account:read"},
|
||||
},
|
||||
scopeCode: "consumer:account:read",
|
||||
want: false, // P4-C-07 核心闭环:supply 用户不能操作 consumer 资源
|
||||
},
|
||||
|
||||
// consumer 用户只能使用 consumer 和 platform scope
|
||||
{
|
||||
name: "consumer_user_can_use_consumer_scope",
|
||||
claims: &IAMTokenClaims{
|
||||
SubjectID: "user1",
|
||||
Role: "consumer_admin",
|
||||
UserType: "consumer",
|
||||
Scope: []string{"consumer:account:read"},
|
||||
},
|
||||
scopeCode: "consumer:account:read",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "consumer_user_can_use_platform_scope",
|
||||
claims: &IAMTokenClaims{
|
||||
SubjectID: "user1",
|
||||
Role: "consumer_admin",
|
||||
UserType: "consumer",
|
||||
Scope: []string{"platform:read"},
|
||||
},
|
||||
scopeCode: "platform:read",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "consumer_user_cannot_use_supply_scope",
|
||||
claims: &IAMTokenClaims{
|
||||
SubjectID: "user1",
|
||||
Role: "consumer_admin",
|
||||
UserType: "consumer",
|
||||
Scope: []string{"supply:account:read"},
|
||||
},
|
||||
scopeCode: "supply:account:read",
|
||||
want: false, // P4-C-07 核心闭环:consumer 用户不能操作 supply 资源
|
||||
},
|
||||
|
||||
// 边界情况
|
||||
{
|
||||
name: "nil_claims_returns_false",
|
||||
claims: nil,
|
||||
scopeCode: "supply:account:read",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "empty_scope_passes",
|
||||
claims: &IAMTokenClaims{
|
||||
SubjectID: "user1",
|
||||
Role: "supply_admin",
|
||||
UserType: "supply",
|
||||
Scope: []string{},
|
||||
},
|
||||
scopeCode: "",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "wildcard_scope_still_checks_user_type",
|
||||
claims: &IAMTokenClaims{
|
||||
SubjectID: "user1",
|
||||
Role: "supply_admin",
|
||||
UserType: "supply",
|
||||
Scope: []string{"*", "supply:account:read"},
|
||||
},
|
||||
scopeCode: "consumer:account:read",
|
||||
want: false, // wildcard跳过scope持有但仍校验UserType,supply用户不能访问consumer scope
|
||||
},
|
||||
{
|
||||
name: "unknown_scope_type_rejected",
|
||||
claims: &IAMTokenClaims{
|
||||
SubjectID: "user1",
|
||||
Role: "supply_admin",
|
||||
UserType: "supply",
|
||||
Scope: []string{"unknown:read"},
|
||||
},
|
||||
scopeCode: "unknown:read",
|
||||
want: false, // 未知类型保守拒绝
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ValidateScopeCodeMatch(tt.claims, tt.scopeCode)
|
||||
if got != tt.want {
|
||||
t.Errorf("ValidateScopeCodeMatch() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequireScopeWithUserType_Middleware 集成测试 RequireScopeWithUserType 中间件
|
||||
func TestRequireScopeWithUserType_Middleware(t *testing.T) {
|
||||
m := NewScopeAuthMiddleware()
|
||||
nextCalled := false
|
||||
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
nextCalled = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
claims *IAMTokenClaims
|
||||
requiredScope string
|
||||
wantStatus int
|
||||
wantNextCalled bool
|
||||
}{
|
||||
{
|
||||
name: "supply_user_access_supply_scope_allowed",
|
||||
claims: &IAMTokenClaims{
|
||||
SubjectID: "user1",
|
||||
Role: "supply_admin",
|
||||
UserType: "supply",
|
||||
Scope: []string{"supply:account:read"},
|
||||
},
|
||||
requiredScope: "supply:account:read",
|
||||
wantStatus: http.StatusOK,
|
||||
wantNextCalled: true,
|
||||
},
|
||||
{
|
||||
name: "supply_user_access_consumer_scope_rejected",
|
||||
claims: &IAMTokenClaims{
|
||||
SubjectID: "user1",
|
||||
Role: "supply_admin",
|
||||
UserType: "supply",
|
||||
Scope: []string{"consumer:account:read"},
|
||||
},
|
||||
requiredScope: "consumer:account:read",
|
||||
wantStatus: http.StatusForbidden,
|
||||
wantNextCalled: false,
|
||||
},
|
||||
{
|
||||
name: "platform_user_access_consumer_scope_allowed",
|
||||
claims: &IAMTokenClaims{
|
||||
SubjectID: "user1",
|
||||
Role: "super_admin",
|
||||
UserType: "platform",
|
||||
Scope: []string{"consumer:account:read"},
|
||||
},
|
||||
requiredScope: "consumer:account:read",
|
||||
wantStatus: http.StatusOK,
|
||||
wantNextCalled: true,
|
||||
},
|
||||
{
|
||||
name: "missing_claims_returns_401",
|
||||
claims: nil,
|
||||
requiredScope: "supply:account:read",
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
wantNextCalled: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
nextCalled = false
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
if tt.claims != nil {
|
||||
req = req.WithContext(WithIAMClaims(context.Background(), tt.claims))
|
||||
}
|
||||
|
||||
handler := m.RequireScopeWithUserType(tt.requiredScope)(nextHandler)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != tt.wantStatus {
|
||||
t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus)
|
||||
}
|
||||
if nextCalled != tt.wantNextCalled {
|
||||
t.Errorf("nextCalled = %v, want %v", nextCalled, tt.wantNextCalled)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -24,12 +24,12 @@ var (
|
||||
// Scope Scope模型
|
||||
// 对应数据库 iam_scopes 表
|
||||
type Scope struct {
|
||||
ID int64 // 主键ID
|
||||
Code string // Scope代码 (unique): platform:read, supply:account:write
|
||||
Name string // Scope名称
|
||||
Type string // Scope类型: platform, supply, consumer, router, billing
|
||||
Description string // 描述
|
||||
IsActive bool // 是否激活
|
||||
ID int64 // 主键ID
|
||||
Code string // Scope代码 (unique): platform:read, supply:account:write
|
||||
Name string // Scope名称
|
||||
Type string // Scope类型: platform, supply, consumer, router, billing
|
||||
Description string // 描述
|
||||
IsActive bool // 是否激活
|
||||
|
||||
// 审计字段
|
||||
RequestID string // 请求追踪ID
|
||||
@@ -46,14 +46,14 @@ type Scope struct {
|
||||
func NewScope(code, name, scopeType string) *Scope {
|
||||
now := time.Now()
|
||||
return &Scope{
|
||||
Code: code,
|
||||
Name: name,
|
||||
Type: scopeType,
|
||||
IsActive: true,
|
||||
RequestID: generateRequestID(),
|
||||
Version: 1,
|
||||
CreatedAt: &now,
|
||||
UpdatedAt: &now,
|
||||
Code: code,
|
||||
Name: name,
|
||||
Type: scopeType,
|
||||
IsActive: true,
|
||||
RequestID: generateRequestID(),
|
||||
Version: 1,
|
||||
CreatedAt: &now,
|
||||
UpdatedAt: &now,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,3 +223,24 @@ func GetPredefinedScopeByCode(code string) *Scope {
|
||||
func IsPredefinedScope(code string) bool {
|
||||
return GetPredefinedScopeByCode(code) != nil
|
||||
}
|
||||
|
||||
// ValidateUserTypeScopeMatch 验证userType与scopeType是否匹配
|
||||
// 平台管理员(platform)可使用所有类型的scope
|
||||
// 供应商(supply)只能使用supply和platform类型的scope
|
||||
// 消费者(consumer)只能使用consumer和platform类型的scope
|
||||
func ValidateUserTypeScopeMatch(userType, scopeType string) bool {
|
||||
switch userType {
|
||||
case ScopeTypePlatform:
|
||||
// platform用户可使用所有scope类型
|
||||
return true
|
||||
case ScopeTypeSupply:
|
||||
// supply用户只能使用supply和platform类型scope(不能操作consumer资源)
|
||||
return scopeType == ScopeTypeSupply || scopeType == ScopeTypePlatform
|
||||
case ScopeTypeConsumer:
|
||||
// consumer用户只能使用consumer和platform类型scope(不能操作supply资源)
|
||||
return scopeType == ScopeTypeConsumer || scopeType == ScopeTypePlatform
|
||||
default:
|
||||
// 未知userType默认严格模式,拒绝访问
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user