diff --git a/docs/plans/2026-04-21-iam-tenant-operator-scope-analysis.md b/docs/plans/2026-04-21-iam-tenant-operator-scope-analysis.md new file mode 100644 index 00000000..05056b3b --- /dev/null +++ b/docs/plans/2026-04-21-iam-tenant-operator-scope-analysis.md @@ -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) diff --git a/supply-api/internal/audit/audit.go b/supply-api/internal/audit/audit.go index 563fd6ea..0db35c03 100644 --- a/supply-api/internal/audit/audit.go +++ b/supply-api/internal/audit/audit.go @@ -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 +} diff --git a/supply-api/internal/domain/account.go b/supply-api/internal/domain/account.go index 0eb7a825..78f1525d 100644 --- a/supply-api/internal/domain/account.go +++ b/supply-api/internal/domain/account.go @@ -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{}{ diff --git a/supply-api/internal/domain/package.go b/supply-api/internal/domain/package.go index 19f0d28d..d110f2ad 100644 --- a/supply-api/internal/domain/package.go +++ b/supply-api/internal/domain/package.go @@ -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{}{ diff --git a/supply-api/internal/domain/settlement.go b/supply-api/internal/domain/settlement.go index be32183f..1ac7ea66 100644 --- a/supply-api/internal/domain/settlement.go +++ b/supply-api/internal/domain/settlement.go @@ -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{}{ diff --git a/supply-api/internal/iam/middleware/scope_auth.go b/supply-api/internal/iam/middleware/scope_auth.go index 535594b0..162fedbe 100644 --- a/supply-api/internal/iam/middleware/scope_auth.go +++ b/supply-api/internal/iam/middleware/scope_auth.go @@ -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 diff --git a/supply-api/internal/iam/middleware/scope_usertype_test.go b/supply-api/internal/iam/middleware/scope_usertype_test.go new file mode 100644 index 00000000..29626fac --- /dev/null +++ b/supply-api/internal/iam/middleware/scope_usertype_test.go @@ -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) + } + }) + } +} diff --git a/supply-api/internal/iam/model/scope.go b/supply-api/internal/iam/model/scope.go index 12e61da9..8caa332f 100644 --- a/supply-api/internal/iam/model/scope.go +++ b/supply-api/internal/iam/model/scope.go @@ -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 + } +}