diff --git a/api/init.go b/api/init.go index ad70126..bf85cfd 100644 --- a/api/init.go +++ b/api/init.go @@ -12,6 +12,8 @@ import ( "github.com/veypi/vbase/api/middleware" "github.com/veypi/vbase/api/oauth" "github.com/veypi/vbase/api/org" + "github.com/veypi/vbase/api/policy" + "github.com/veypi/vbase/api/role" "github.com/veypi/vbase/api/user" "github.com/veypi/vigo" "github.com/veypi/vigo/contrib/common" @@ -30,6 +32,8 @@ func init() { Router.Extend("/users", user.Router) Router.Extend("/orgs", org.Router) Router.Extend("/oauth", oauth.Router) + Router.Extend("/policies", policy.Router) + Router.Extend("/roles", role.Router) // 404 处理 Router.Any("/**", vigo.SkipBefore, "拦截未注册的api请求,返回404", func(x *vigo.X) error { diff --git a/api/middleware/init.go b/api/middleware/init.go new file mode 100644 index 0000000..470f531 --- /dev/null +++ b/api/middleware/init.go @@ -0,0 +1,162 @@ +// +// Copyright (C) 2024 veypi +// 2025-03-04 16:08:06 +// Distributed under terms of the MIT license. +// + +package middleware + +import ( + "github.com/veypi/vbase/cfg" + "github.com/veypi/vbase/models" +) + +// InitOrgPolicies 为组织初始化默认策略和角色 +func InitOrgPolicies(orgID string) error { + // 创建默认策略 + policies := getDefaultPolicies() + for _, policy := range policies { + var count int64 + cfg.DB().Model(&models.Policy{}).Where("code = ?", policy.Code).Count(&count) + if count == 0 { + if err := cfg.DB().Create(&policy).Error; err != nil { + return err + } + } + } + + // 创建默认角色 + roles := getDefaultRoles() + for _, role := range roles { + role.OrgID = orgID + var count int64 + cfg.DB().Model(&models.Role{}).Where("code = ? AND org_id = ?", role.Code, orgID).Count(&count) + if count == 0 { + if err := cfg.DB().Create(&role).Error; err != nil { + return err + } + } + } + + return nil +} + +// getDefaultPolicies 获取默认策略列表 +func getDefaultPolicies() []models.Policy { + return []models.Policy{ + { + Code: "user:read", + Name: "读取用户信息", + Resource: "user", + Action: "read", + Effect: models.PolicyEffectAllow, + Scope: models.PolicyScopeOrg, + }, + { + Code: "user:update", + Name: "更新用户信息", + Resource: "user", + Action: "update", + Effect: models.PolicyEffectAllow, + Condition: "owner", + Scope: models.PolicyScopeOrg, + }, + { + Code: "role:manage", + Name: "管理角色", + Resource: "role", + Action: "*", + Effect: models.PolicyEffectAllow, + Condition: "admin", + Scope: models.PolicyScopeOrg, + }, + { + Code: "policy:manage", + Name: "管理策略", + Resource: "policy", + Action: "*", + Effect: models.PolicyEffectAllow, + Condition: "admin", + Scope: models.PolicyScopeOrg, + }, + { + Code: "org:read", + Name: "读取组织信息", + Resource: "org", + Action: "read", + Effect: models.PolicyEffectAllow, + Scope: models.PolicyScopeOrg, + }, + { + Code: "org:update", + Name: "更新组织信息", + Resource: "org", + Action: "update", + Effect: models.PolicyEffectAllow, + Condition: "admin", + Scope: models.PolicyScopeOrg, + }, + { + Code: "org:delete", + Name: "删除组织", + Resource: "org", + Action: "delete", + Effect: models.PolicyEffectAllow, + Condition: "owner", + Scope: models.PolicyScopeOrg, + }, + { + Code: "member:manage", + Name: "管理成员", + Resource: "org_member", + Action: "*", + Effect: models.PolicyEffectAllow, + Condition: "admin", + Scope: models.PolicyScopeOrg, + }, + { + Code: "resource:read", + Name: "读取资源", + Resource: "resource", + Action: "read", + Effect: models.PolicyEffectAllow, + Scope: models.PolicyScopeOrg, + }, + { + Code: "resource:write", + Name: "写入资源", + Resource: "resource", + Action: "create,update,delete", + Effect: models.PolicyEffectAllow, + Condition: "owner", + Scope: models.PolicyScopeOrg, + }, + } +} + +// getDefaultRoles 获取默认角色列表 +func getDefaultRoles() []models.Role { + return []models.Role{ + { + Name: "管理员", + Code: models.RoleCodeAdmin, + Description: "组织管理员,可以管理成员、角色和策略", + Scope: models.PolicyScopeOrg, + IsSystem: true, + }, + { + Name: "开发者", + Code: models.RoleCodeDeveloper, + Description: "开发者,可以创建和管理资源", + Scope: models.PolicyScopeOrg, + IsSystem: true, + }, + { + Name: "只读用户", + Code: models.RoleCodeViewer, + Description: "只读访问权限", + Scope: models.PolicyScopeOrg, + IsSystem: true, + }, + } +} diff --git a/api/middleware/perm.go b/api/middleware/perm.go new file mode 100644 index 0000000..68b2899 --- /dev/null +++ b/api/middleware/perm.go @@ -0,0 +1,155 @@ +// +// Copyright (C) 2024 veypi +// 2025-03-04 16:08:06 +// Distributed under terms of the MIT license. +// + +package middleware + +import ( + "strings" + + "github.com/veypi/vbase/cfg" + "github.com/veypi/vbase/models" + "github.com/veypi/vigo" +) + +// Checker 权限检查器 +type Checker struct { + userID string + orgID string + roles []string + org *models.Org +} + +// NewChecker 创建权限检查器 +func NewChecker(x *vigo.X) *Checker { + c := &Checker{} + + if uid, ok := x.Get("user_id").(string); ok { + c.userID = uid + } + if oid, ok := x.Get("org_id").(string); ok { + c.orgID = oid + } + if roles, ok := x.Get("org_roles").(string); ok && roles != "" { + c.roles = strings.Split(roles, ",") + } + + return c +} + +// IsOrgOwner 检查用户是否是组织所有者 +func (c *Checker) IsOrgOwner() bool { + if c.orgID == "" || c.userID == "" { + return false + } + + if c.org != nil { + return c.org.OwnerID == c.userID + } + + var org models.Org + if err := cfg.DB().First(&org, "id = ?", c.orgID).Error; err != nil { + return false + } + c.org = &org + + return org.OwnerID == c.userID +} + +// IsOrgAdmin 检查用户是否是组织管理员 +func (c *Checker) IsOrgAdmin() bool { + if c.IsOrgOwner() { + return true + } + + for _, roleID := range c.roles { + var role models.Role + if err := cfg.DB().First(&role, "id = ?", roleID).Error; err != nil { + continue + } + if role.Code == models.RoleCodeAdmin { + return true + } + } + + return false +} + +// HasRole 检查用户是否有指定角色 +func (c *Checker) HasRole(roleCode string) bool { + for _, roleID := range c.roles { + var role models.Role + if err := cfg.DB().First(&role, "id = ?", roleID).Error; err != nil { + continue + } + if role.Code == roleCode { + return true + } + } + return false +} + +// RequireAdmin 要求管理员权限 +func (c *Checker) RequireAdmin() error { + if !c.IsOrgAdmin() { + return vigo.ErrForbidden.WithString("admin permission required") + } + return nil +} + +// RequireOwner 要求所有者权限 +func (c *Checker) RequireOwner() error { + if !c.IsOrgOwner() { + return vigo.ErrForbidden.WithString("owner permission required") + } + return nil +} + +// RequireOrg 要求必须在组织上下文中 +func (c *Checker) RequireOrg() error { + if c.orgID == "" { + return vigo.ErrArgInvalid.WithString("organization context required") + } + return nil +} + +// UserID 获取用户ID +func (c *Checker) UserID() string { + return c.userID +} + +// OrgID 获取组织ID +func (c *Checker) OrgID() string { + return c.orgID +} + +// GetUserRoles 获取用户角色列表 +func (c *Checker) GetUserRoles() []string { + return c.roles +} + +// RequireAdmin 中间件:要求管理员权限 +func RequireAdmin() func(*vigo.X) error { + return func(x *vigo.X) error { + checker := NewChecker(x) + return checker.RequireAdmin() + } +} + +// RequireOwner 中间件:要求组织所有者权限 +func RequireOwner() func(*vigo.X) error { + return func(x *vigo.X) error { + checker := NewChecker(x) + return checker.RequireOwner() + } +} + +// RequireOrgContext 中间件:要求必须在组织上下文中 +func RequireOrgContext() func(*vigo.X) error { + return func(x *vigo.X) error { + checker := NewChecker(x) + return checker.RequireOrg() + } +} diff --git a/api/middleware/policy.go b/api/middleware/policy.go new file mode 100644 index 0000000..84ced3c --- /dev/null +++ b/api/middleware/policy.go @@ -0,0 +1,224 @@ +// +// Copyright (C) 2024 veypi +// 2025-03-04 16:08:06 +// Distributed under terms of the MIT license. +// + +package middleware + +import ( + "strings" + + "github.com/veypi/vbase/cfg" + "github.com/veypi/vbase/models" + "github.com/veypi/vigo" +) + +// PolicyEvaluator 策略评估器 +type PolicyEvaluator struct { + checker *Checker +} + +// NewPolicyEvaluator 创建策略评估器 +func (c *Checker) NewPolicyEvaluator() *PolicyEvaluator { + return &PolicyEvaluator{checker: c} +} + +// PermissionCheck 权限检查参数 +type PermissionCheck struct { + Resource string // 资源: user, org, role, policy, project, etc. + Action string // 操作: create, read, update, delete, list, etc. + OrgID string // 组织ID(可选) + OwnerID string // 资源所有者ID(用于条件判断) +} + +// HasPermission 检查是否有权限 +func (pe *PolicyEvaluator) HasPermission(check PermissionCheck) bool { + if pe.checker.userID == "" { + return false + } + + // 平台超级管理员拥有所有权限 + if pe.isPlatformAdmin() { + return true + } + + // 获取用户所有策略 + policies := pe.getUserPolicies(check.OrgID) + + // 按优先级排序:deny > allow + // 先检查 deny 策略 + for _, policy := range policies { + if policy.Effect != models.PolicyEffectDeny { + continue + } + if pe.matchPolicy(policy, check) { + return false + } + } + + // 再检查 allow 策略 + for _, policy := range policies { + if policy.Effect != models.PolicyEffectAllow { + continue + } + if pe.matchPolicy(policy, check) { + return true + } + } + + return false +} + +// isPlatformAdmin 检查是否为平台管理员 +func (pe *PolicyEvaluator) isPlatformAdmin() bool { + return pe.checker.IsOrgOwner() +} + +// getUserPolicies 获取用户拥有的所有策略 +func (pe *PolicyEvaluator) getUserPolicies(orgID string) []models.Policy { + var policies []models.Policy + + roles := pe.checker.GetUserRoles() + if len(roles) == 0 { + return policies + } + + for _, roleID := range roles { + var role models.Role + if err := cfg.DB().First(&role, "id = ?", roleID).Error; err != nil { + continue + } + + if role.PolicyIDs == "" { + continue + } + + policyIDs := strings.Split(role.PolicyIDs, ",") + var rolePolicies []models.Policy + if err := cfg.DB().Where("id IN ?", policyIDs).Find(&rolePolicies).Error; err != nil { + continue + } + + policies = append(policies, rolePolicies...) + } + + return policies +} + +// matchPolicy 匹配策略 +func (pe *PolicyEvaluator) matchPolicy(policy models.Policy, check PermissionCheck) bool { + if !matchPattern(policy.Resource, check.Resource) { + return false + } + + if !matchPattern(policy.Action, check.Action) { + return false + } + + if policy.Condition != "" { + if !pe.checkCondition(policy.Condition, check) { + return false + } + } + + return true +} + +// matchPattern 模式匹配(支持通配符 *) +func matchPattern(pattern, value string) bool { + if pattern == "*" { + return true + } + if pattern == value { + return true + } + if strings.HasSuffix(pattern, ":*") { + prefix := strings.TrimSuffix(pattern, ":*") + return strings.HasPrefix(value, prefix+":") + } + return false +} + +// checkCondition 检查条件 +func (pe *PolicyEvaluator) checkCondition(condition string, check PermissionCheck) bool { + conditions := strings.Split(condition, ",") + for _, c := range conditions { + c = strings.TrimSpace(c) + switch c { + case "owner": + if check.OwnerID != "" && pe.checker.UserID() == check.OwnerID { + return true + } + case "org_member": + if pe.checker.OrgID() != "" { + return true + } + case "admin": + if pe.checker.IsOrgAdmin() { + return true + } + } + } + return false +} + +// RequirePermission 要求特定权限 +func (c *Checker) RequirePermission(resource, action string) error { + pe := c.NewPolicyEvaluator() + if !pe.HasPermission(PermissionCheck{ + Resource: resource, + Action: action, + OrgID: c.OrgID(), + }) { + return vigo.ErrForbidden.WithString("permission denied: " + resource + ":" + action) + } + return nil +} + +// Permission 基于策略的权限检查中间件 +func Permission(resource, action string) func(*vigo.X) error { + return func(x *vigo.X) error { + checker := NewChecker(x) + return checker.RequirePermission(resource, action) + } +} + +// PermissionWithOwner 带资源所有者检查的权限中间件 +func PermissionWithOwner(resource, action, ownerIDKey string) func(*vigo.X) error { + return func(x *vigo.X) error { + checker := NewChecker(x) + + ownerID := "" + if oid, ok := x.Get(ownerIDKey).(string); ok { + ownerID = oid + } + + if ownerID != "" && checker.UserID() == ownerID { + return nil + } + + return checker.RequirePermission(resource, action) + } +} + +// AdminOrOwner 管理员或所有者权限 +func AdminOrOwner(ownerIDKey string) func(*vigo.X) error { + return func(x *vigo.X) error { + checker := NewChecker(x) + + if checker.IsOrgAdmin() { + return nil + } + + ownerID := "" + if oid, ok := x.Get(ownerIDKey).(string); ok { + ownerID = oid + } + if ownerID != "" && checker.UserID() == ownerID { + return nil + } + + return vigo.ErrForbidden.WithString("admin or owner permission required") + } +} diff --git a/api/policy/create.go b/api/policy/create.go new file mode 100644 index 0000000..49ad29f --- /dev/null +++ b/api/policy/create.go @@ -0,0 +1,57 @@ +// +// Copyright (C) 2024 veypi +// 2025-03-04 16:08:06 +// Distributed under terms of the MIT license. +// + +package policy + +import ( + "github.com/veypi/vbase/cfg" + "github.com/veypi/vbase/models" + "github.com/veypi/vigo" +) + +type CreateRequest struct { + Code string `json:"code" src:"json" desc:"策略代码"` + Name string `json:"name" src:"json" desc:"策略名称"` + Description string `json:"description,omitempty" src:"json" desc:"描述"` + Resource string `json:"resource" src:"json" desc:"资源: user/org/resource/*"` + Action string `json:"action" src:"json" desc:"操作: create/read/update/delete/*"` + Effect string `json:"effect" src:"json" desc:"效果: allow/deny"` + Condition string `json:"condition,omitempty" src:"json" desc:"条件: owner/org_member"` + Scope string `json:"scope" src:"json" desc:"作用域: platform/org/resource"` +} + +func create(x *vigo.X, req *CreateRequest) (*models.Policy, error) { + // 检查代码是否已存在 + var count int64 + cfg.DB().Model(&models.Policy{}).Where("code = ?", req.Code).Count(&count) + if count > 0 { + return nil, vigo.ErrArgInvalid.WithString("policy code already exists") + } + + policy := &models.Policy{ + Code: req.Code, + Name: req.Name, + Description: req.Description, + Resource: req.Resource, + Action: req.Action, + Effect: req.Effect, + Condition: req.Condition, + Scope: req.Scope, + } + + if policy.Effect == "" { + policy.Effect = models.PolicyEffectAllow + } + if policy.Scope == "" { + policy.Scope = models.PolicyScopeOrg + } + + if err := cfg.DB().Create(policy).Error; err != nil { + return nil, vigo.ErrInternalServer.WithError(err) + } + + return policy, nil +} diff --git a/api/policy/del.go b/api/policy/del.go new file mode 100644 index 0000000..8abca72 --- /dev/null +++ b/api/policy/del.go @@ -0,0 +1,35 @@ +// +// Copyright (C) 2024 veypi +// 2025-03-04 16:08:06 +// Distributed under terms of the MIT license. +// + +package policy + +import ( + "github.com/veypi/vbase/cfg" + "github.com/veypi/vbase/models" + "github.com/veypi/vigo" +) + +type DeleteRequest struct { + PolicyID string `src:"path@policy_id" desc:"策略ID"` +} + +func del(x *vigo.X, req *DeleteRequest) error { + var policy models.Policy + if err := cfg.DB().First(&policy, "id = ?", req.PolicyID).Error; err != nil { + return vigo.ErrNotFound + } + + // 系统策略不允许删除 + if policy.Scope == models.PolicyScopePlatform { + return vigo.ErrForbidden.WithString("system policies cannot be deleted") + } + + if err := cfg.DB().Delete(&policy).Error; err != nil { + return vigo.ErrInternalServer.WithError(err) + } + + return nil +} diff --git a/api/policy/get.go b/api/policy/get.go new file mode 100644 index 0000000..3d090c6 --- /dev/null +++ b/api/policy/get.go @@ -0,0 +1,25 @@ +// +// Copyright (C) 2024 veypi +// 2025-03-04 16:08:06 +// Distributed under terms of the MIT license. +// + +package policy + +import ( + "github.com/veypi/vbase/cfg" + "github.com/veypi/vbase/models" + "github.com/veypi/vigo" +) + +type GetRequest struct { + PolicyID string `src:"path@policy_id" desc:"策略ID"` +} + +func get(x *vigo.X, req *GetRequest) (*models.Policy, error) { + var policy models.Policy + if err := cfg.DB().First(&policy, "id = ?", req.PolicyID).Error; err != nil { + return nil, vigo.ErrNotFound + } + return &policy, nil +} diff --git a/api/policy/init.go b/api/policy/init.go new file mode 100644 index 0000000..24b4151 --- /dev/null +++ b/api/policy/init.go @@ -0,0 +1,25 @@ +// +// Copyright (C) 2024 veypi +// 2025-03-04 16:08:06 +// Distributed under terms of the MIT license. +// + +package policy + +import ( + "github.com/veypi/vbase/api/middleware" + "github.com/veypi/vigo" +) + +var Router = vigo.NewRouter() + +func init() { + // 列表和详情 - 需要读取权限 + Router.Get("/", middleware.Permission("policy", "list"), "策略列表", list) + Router.Get("/{policy_id}", middleware.Permission("policy", "read"), "获取策略详情", get) + + // 创建、更新、删除 - 需要管理权限 + Router.Post("/", middleware.Permission("policy", "create"), "创建策略", create) + Router.Patch("/{policy_id}", middleware.Permission("policy", "update"), "更新策略", patch) + Router.Delete("/{policy_id}", middleware.Permission("policy", "delete"), "删除策略", del) +} diff --git a/api/policy/list.go b/api/policy/list.go new file mode 100644 index 0000000..8efa1fb --- /dev/null +++ b/api/policy/list.go @@ -0,0 +1,63 @@ +// +// Copyright (C) 2024 veypi +// 2025-03-04 16:08:06 +// Distributed under terms of the MIT license. +// + +package policy + +import ( + "github.com/veypi/vbase/cfg" + "github.com/veypi/vbase/models" + "github.com/veypi/vigo" +) + +type ListRequest struct { + Page int `json:"page" src:"query" default:"1"` + PageSize int `json:"page_size" src:"query" default:"20"` + Resource string `json:"resource" src:"query" desc:"资源类型筛选"` + Scope string `json:"scope" src:"query" desc:"作用域筛选"` +} + +type ListResponse struct { + Items []models.Policy `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` + TotalPages int `json:"total_pages"` +} + +func list(x *vigo.X, req *ListRequest) (*ListResponse, error) { + db := cfg.DB().Model(&models.Policy{}) + + if req.Resource != "" { + db = db.Where("resource = ?", req.Resource) + } + if req.Scope != "" { + db = db.Where("scope = ?", req.Scope) + } + + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, vigo.ErrInternalServer.WithError(err) + } + + var policies []models.Policy + offset := (req.Page - 1) * req.PageSize + if err := db.Order("created_at DESC").Offset(offset).Limit(req.PageSize).Find(&policies).Error; err != nil { + return nil, vigo.ErrInternalServer.WithError(err) + } + + totalPages := int(total) / req.PageSize + if int(total)%req.PageSize > 0 { + totalPages++ + } + + return &ListResponse{ + Items: policies, + Total: total, + Page: req.Page, + PageSize: req.PageSize, + TotalPages: totalPages, + }, nil +} diff --git a/api/policy/patch.go b/api/policy/patch.go new file mode 100644 index 0000000..4bbc35e --- /dev/null +++ b/api/policy/patch.go @@ -0,0 +1,53 @@ +// +// Copyright (C) 2024 veypi +// 2025-03-04 16:08:06 +// Distributed under terms of the MIT license. +// + +package policy + +import ( + "github.com/veypi/vbase/cfg" + "github.com/veypi/vbase/models" + "github.com/veypi/vigo" +) + +type PatchRequest struct { + PolicyID string `src:"path@policy_id" desc:"策略ID"` + Name *string `json:"name,omitempty" src:"json" desc:"策略名称"` + Description *string `json:"description,omitempty" src:"json" desc:"描述"` + Effect *string `json:"effect,omitempty" src:"json" desc:"效果: allow/deny"` + Condition *string `json:"condition,omitempty" src:"json" desc:"条件"` +} + +func patch(x *vigo.X, req *PatchRequest) (*models.Policy, error) { + var policy models.Policy + if err := cfg.DB().First(&policy, "id = ?", req.PolicyID).Error; err != nil { + return nil, vigo.ErrNotFound + } + + // 系统策略不允许修改 + if policy.Scope == models.PolicyScopePlatform { + return nil, vigo.ErrForbidden.WithString("system policies cannot be modified") + } + + updates := make(map[string]any) + if req.Name != nil { + updates["name"] = *req.Name + } + if req.Description != nil { + updates["description"] = *req.Description + } + if req.Effect != nil { + updates["effect"] = *req.Effect + } + if req.Condition != nil { + updates["condition"] = *req.Condition + } + + if err := cfg.DB().Model(&policy).Updates(updates).Error; err != nil { + return nil, vigo.ErrInternalServer.WithError(err) + } + + return &policy, nil +} diff --git a/api/role/create.go b/api/role/create.go new file mode 100644 index 0000000..bbf73ce --- /dev/null +++ b/api/role/create.go @@ -0,0 +1,52 @@ +// Copyright (C) 2024 veypi +// 2025-03-04 16:08:06 +// Distributed under terms of the MIT license. + +package role + +import ( + "github.com/veypi/vbase/cfg" + "github.com/veypi/vbase/models" + "github.com/veypi/vigo" +) + +type CreateRequest struct { + OrgID string `json:"org_id" src:"json" desc:"组织ID"` + Name string `json:"name" src:"json" desc:"角色名称"` + Code string `json:"code" src:"json" desc:"角色代码"` + Description string `json:"description,omitempty" src:"json" desc:"描述"` + PolicyIDs []string `json:"policy_ids,omitempty" src:"json" desc:"策略ID列表"` +} + +func create(x *vigo.X, req *CreateRequest) (*models.Role, error) { + // 检查同一组织内代码是否已存在 + var count int64 + cfg.DB().Model(&models.Role{}).Where("org_id = ? AND code = ?", req.OrgID, req.Code).Count(&count) + if count > 0 { + return nil, vigo.ErrArgInvalid.WithString("role code already exists in this organization") + } + + // 转换策略ID列表为字符串 + policyIDsStr := "" + for i, id := range req.PolicyIDs { + if i > 0 { + policyIDsStr += "," + } + policyIDsStr += id + } + + role := &models.Role{ + OrgID: req.OrgID, + Name: req.Name, + Code: req.Code, + Description: req.Description, + PolicyIDs: policyIDsStr, + Scope: models.PolicyScopeOrg, + } + + if err := cfg.DB().Create(role).Error; err != nil { + return nil, vigo.ErrInternalServer.WithError(err) + } + + return role, nil +} diff --git a/api/role/del.go b/api/role/del.go new file mode 100644 index 0000000..44fd77f --- /dev/null +++ b/api/role/del.go @@ -0,0 +1,33 @@ +// Copyright (C) 2024 veypi +// 2025-03-04 16:08:06 +// Distributed under terms of the MIT license. + +package role + +import ( + "github.com/veypi/vbase/cfg" + "github.com/veypi/vbase/models" + "github.com/veypi/vigo" +) + +type DeleteRequest struct { + RoleID string `src:"path@role_id" desc:"角色ID"` +} + +func del(x *vigo.X, req *DeleteRequest) error { + var role models.Role + if err := cfg.DB().First(&role, "id = ?", req.RoleID).Error; err != nil { + return vigo.ErrNotFound + } + + // 系统角色不允许删除 + if role.IsSystem { + return vigo.ErrForbidden.WithString("system roles cannot be deleted") + } + + if err := cfg.DB().Delete(&role).Error; err != nil { + return vigo.ErrInternalServer.WithError(err) + } + + return nil +} diff --git a/api/role/get.go b/api/role/get.go new file mode 100644 index 0000000..8a92b2d --- /dev/null +++ b/api/role/get.go @@ -0,0 +1,36 @@ +// Copyright (C) 2024 veypi +// 2025-03-04 16:08:06 +// Distributed under terms of the MIT license. + +package role + +import ( + "github.com/veypi/vbase/cfg" + "github.com/veypi/vbase/models" + "github.com/veypi/vigo" +) + +type GetRequest struct { + RoleID string `src:"path@role_id" desc:"角色ID"` +} + +type RoleDetail struct { + models.Role + Policies []models.Policy `json:"policies"` +} + +func get(x *vigo.X, req *GetRequest) (*RoleDetail, error) { + var role models.Role + if err := cfg.DB().First(&role, "id = ?", req.RoleID).Error; err != nil { + return nil, vigo.ErrNotFound + } + + // 解析策略ID列表 + policies := []models.Policy{} + // TODO: 根据 role.PolicyIDs 查询策略列表 + + return &RoleDetail{ + Role: role, + Policies: policies, + }, nil +} diff --git a/api/role/init.go b/api/role/init.go new file mode 100644 index 0000000..94fc2f8 --- /dev/null +++ b/api/role/init.go @@ -0,0 +1,28 @@ +// Copyright (C) 2024 veypi +// 2025-03-04 16:08:06 +// Distributed under terms of the MIT license. + +package role + +import ( + "github.com/veypi/vbase/api/middleware" + "github.com/veypi/vigo" +) + +var Router = vigo.NewRouter() + +func init() { + // 列表和详情 - 需要读取权限 + Router.Get("/", middleware.Permission("role", "list"), "角色列表", list) + Router.Get("/{role_id}", middleware.Permission("role", "read"), "获取角色详情", get) + + // 创建、更新、删除 - 需要管理权限 + Router.Post("/", middleware.Permission("role", "create"), "创建角色", create) + Router.Patch("/{role_id}", middleware.Permission("role", "update"), "更新角色", patch) + Router.Delete("/{role_id}", middleware.Permission("role", "delete"), "删除角色", del) + + // 角色策略管理 - 需要角色管理权限 + Router.Get("/{role_id}/policies", middleware.Permission("role", "read"), "获取角色策略", listPolicies) + Router.Post("/{role_id}/policies", middleware.Permission("role", "update"), "为角色添加策略", addPolicy) + Router.Delete("/{role_id}/policies/{policy_id}", middleware.Permission("role", "update"), "从角色移除策略", removePolicy) +} diff --git a/api/role/list.go b/api/role/list.go new file mode 100644 index 0000000..9474493 --- /dev/null +++ b/api/role/list.go @@ -0,0 +1,57 @@ +// Copyright (C) 2024 veypi +// 2025-03-04 16:08:06 +// Distributed under terms of the MIT license. + +package role + +import ( + "github.com/veypi/vbase/cfg" + "github.com/veypi/vbase/models" + "github.com/veypi/vigo" +) + +type ListRequest struct { + Page int `json:"page" src:"query" default:"1"` + PageSize int `json:"page_size" src:"query" default:"20"` + OrgID string `json:"org_id" src:"query" desc:"组织ID筛选"` +} + +type ListResponse struct { + Items []models.Role `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` + TotalPages int `json:"total_pages"` +} + +func list(x *vigo.X, req *ListRequest) (*ListResponse, error) { + db := cfg.DB().Model(&models.Role{}) + + if req.OrgID != "" { + db = db.Where("org_id = ?", req.OrgID) + } + + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, vigo.ErrInternalServer.WithError(err) + } + + var roles []models.Role + offset := (req.Page - 1) * req.PageSize + if err := db.Order("created_at DESC").Offset(offset).Limit(req.PageSize).Find(&roles).Error; err != nil { + return nil, vigo.ErrInternalServer.WithError(err) + } + + totalPages := int(total) / req.PageSize + if int(total)%req.PageSize > 0 { + totalPages++ + } + + return &ListResponse{ + Items: roles, + Total: total, + Page: req.Page, + PageSize: req.PageSize, + TotalPages: totalPages, + }, nil +} diff --git a/api/role/patch.go b/api/role/patch.go new file mode 100644 index 0000000..d437c1c --- /dev/null +++ b/api/role/patch.go @@ -0,0 +1,54 @@ +// Copyright (C) 2024 veypi +// 2025-03-04 16:08:06 +// Distributed under terms of the MIT license. + +package role + +import ( + "github.com/veypi/vbase/cfg" + "github.com/veypi/vbase/models" + "github.com/veypi/vigo" +) + +type PatchRequest struct { + RoleID string `src:"path@role_id" desc:"角色ID"` + Name *string `json:"name,omitempty" src:"json" desc:"角色名称"` + Description *string `json:"description,omitempty" src:"json" desc:"描述"` + PolicyIDs []string `json:"policy_ids,omitempty" src:"json" desc:"策略ID列表"` +} + +func patch(x *vigo.X, req *PatchRequest) (*models.Role, error) { + var role models.Role + if err := cfg.DB().First(&role, "id = ?", req.RoleID).Error; err != nil { + return nil, vigo.ErrNotFound + } + + // 系统角色不允许修改 + if role.IsSystem { + return nil, vigo.ErrForbidden.WithString("system roles cannot be modified") + } + + updates := make(map[string]any) + if req.Name != nil { + updates["name"] = *req.Name + } + if req.Description != nil { + updates["description"] = *req.Description + } + if req.PolicyIDs != nil { + policyIDsStr := "" + for i, id := range req.PolicyIDs { + if i > 0 { + policyIDsStr += "," + } + policyIDsStr += id + } + updates["policy_ids"] = policyIDsStr + } + + if err := cfg.DB().Model(&role).Updates(updates).Error; err != nil { + return nil, vigo.ErrInternalServer.WithError(err) + } + + return &role, nil +} diff --git a/api/role/policy.go b/api/role/policy.go new file mode 100644 index 0000000..06fd962 --- /dev/null +++ b/api/role/policy.go @@ -0,0 +1,129 @@ +// Copyright (C) 2024 veypi +// 2025-03-04 16:08:06 +// Distributed under terms of the MIT license. + +package role + +import ( + "strings" + + "github.com/veypi/vbase/cfg" + "github.com/veypi/vbase/models" + "github.com/veypi/vigo" +) + +// ListPoliciesRequest 获取角色策略请求 +type ListPoliciesRequest struct { + RoleID string `src:"path@role_id" desc:"角色ID"` +} + +// listPolicies 获取角色关联的策略列表 +func listPolicies(x *vigo.X, req *ListPoliciesRequest) ([]models.Policy, error) { + var role models.Role + if err := cfg.DB().First(&role, "id = ?", req.RoleID).Error; err != nil { + return nil, vigo.ErrNotFound + } + + if role.PolicyIDs == "" { + return []models.Policy{}, nil + } + + // 解析策略ID列表 + policyIDs := strings.Split(role.PolicyIDs, ",") + + var policies []models.Policy + if err := cfg.DB().Where("id IN ?", policyIDs).Find(&policies).Error; err != nil { + return nil, vigo.ErrInternalServer.WithError(err) + } + + return policies, nil +} + +// AddPolicyRequest 添加策略请求 +type AddPolicyRequest struct { + RoleID string `src:"path@role_id" desc:"角色ID"` + PolicyID string `json:"policy_id" src:"json" desc:"策略ID"` +} + +// addPolicy 为角色添加策略 +func addPolicy(x *vigo.X, req *AddPolicyRequest) (*models.Role, error) { + var role models.Role + if err := cfg.DB().First(&role, "id = ?", req.RoleID).Error; err != nil { + return nil, vigo.ErrNotFound + } + + // 验证策略是否存在 + var policy models.Policy + if err := cfg.DB().First(&policy, "id = ?", req.PolicyID).Error; err != nil { + return nil, vigo.ErrArgInvalid.WithString("policy not found") + } + + // 解析现有策略ID + existingIDs := map[string]bool{} + if role.PolicyIDs != "" { + for _, id := range strings.Split(role.PolicyIDs, ",") { + existingIDs[id] = true + } + } + + // 检查是否已存在 + if existingIDs[req.PolicyID] { + return nil, vigo.ErrArgInvalid.WithString("policy already added to this role") + } + + // 添加新策略ID + if role.PolicyIDs != "" { + role.PolicyIDs += "," + } + role.PolicyIDs += req.PolicyID + + if err := cfg.DB().Model(&role).Update("policy_ids", role.PolicyIDs).Error; err != nil { + return nil, vigo.ErrInternalServer.WithError(err) + } + + return &role, nil +} + +// RemovePolicyRequest 移除策略请求 +type RemovePolicyRequest struct { + RoleID string `src:"path@role_id" desc:"角色ID"` + PolicyID string `src:"path@policy_id" desc:"策略ID"` +} + +// removePolicy 从角色移除策略 +func removePolicy(x *vigo.X, req *RemovePolicyRequest) (*models.Role, error) { + var role models.Role + if err := cfg.DB().First(&role, "id = ?", req.RoleID).Error; err != nil { + return nil, vigo.ErrNotFound + } + + // 解析现有策略ID + if role.PolicyIDs == "" { + return nil, vigo.ErrArgInvalid.WithString("role has no policies") + } + + existingIDs := strings.Split(role.PolicyIDs, ",") + newIDs := []string{} + found := false + + for _, id := range existingIDs { + if id == req.PolicyID { + found = true + continue + } + newIDs = append(newIDs, id) + } + + if !found { + return nil, vigo.ErrArgInvalid.WithString("policy not found in this role") + } + + // 更新策略ID列表 + role.PolicyIDs = strings.Join(newIDs, ",") + + if err := cfg.DB().Model(&role).Update("policy_ids", role.PolicyIDs).Error; err != nil { + return nil, vigo.ErrInternalServer.WithError(err) + } + + return &role, nil +} diff --git a/api/sms/init.go b/api/sms/init.go new file mode 100644 index 0000000..205475a --- /dev/null +++ b/api/sms/init.go @@ -0,0 +1,14 @@ +// +// init.go +// Copyright (C) 2025 veypi +// +// Distributed under terms of the MIT license. +// + +package sms + +import "github.com/veypi/vigo" + +var Router = vigo.NewRouter() + + diff --git a/api/sms/send.go b/api/sms/send.go new file mode 100644 index 0000000..361b37e --- /dev/null +++ b/api/sms/send.go @@ -0,0 +1,105 @@ +package sms + +import ( + "fmt" + "time" + + "github.com/veypi/vbase/cfg" + "github.com/veypi/vbase/libs/sms_providers" + "github.com/veypi/vbase/libs/utils" + "github.com/veypi/vbase/models" + "github.com/veypi/vigo" + "github.com/veypi/vigo/contrib/limiter" + "gorm.io/gorm" +) + +// SendCodeRequest 发送验证码请求 +type SendCodeRequest struct { + Phone string `json:"phone" src:"json" desc:"手机号"` + Region string `json:"region" src:"json" desc:"区域"` + Purpose string `json:"purpose" src:"json" desc:"用途"` + TemplateID string `json:"template_id" src:"json" desc:"模板ID"` +} + +// SendCodeResponse 发送验证码响应 +type SendCodeResponse struct { + ID string `json:"id"` + Phone string `json:"phone"` + ExpiresAt time.Time `json:"expires_at"` + Interval int `json:"interval"` // 下次可发送间隔(秒) +} + +var _ = Router.Post("/", "发送验证码", vigo.SkipBefore, limiter.NewAdvancedRequestLimiter(time.Minute*3, 5, time.Second*30).Limit, sendCode) + +// SendCode 发送验证码 +func sendCode(x *vigo.X, req *SendCodeRequest) (*SendCodeResponse, error) { + // 1. 验证手机号 + normalizedPhone := utils.NormalizePhoneNumber(req.Phone) + if !utils.ValidatePhoneNumber(normalizedPhone) { + return nil, fmt.Errorf("invalid phone number: %s", req.Phone) + } + + // 2. 检查区域配置 + provider, err := sms_providers.GetSMSProvider(req.Region) + if err != nil { + return nil, err + } + + // 3. 检查发送频率限制 + if err := CheckSendInterval(normalizedPhone, req.Region, req.Purpose); err != nil { + return nil, err + } + + // 4. 检查每日发送次数限制 + if err := CheckDailyLimit(normalizedPhone, req.Region); err != nil { + return nil, err + } + + // 5. 生成验证码 + code, err := utils.GenerateCode(cfg.Config.SMS.Global.CodeLength) + if err != nil { + return nil, fmt.Errorf("failed to generate code: %w", err) + } + + // 6. 创建验证码记录 + smsCode := &models.SMSCode{ + Phone: normalizedPhone, + Code: code, + Region: req.Region, + Purpose: req.Purpose, + Status: models.CodeStatusPending, + ExpiresAt: time.Now().Add(cfg.Config.SMS.Global.CodeExpiry), + } + + // 7. 发送短信 + sendReq := &sms_providers.SendSMSRequest{ + Phone: normalizedPhone, + TemplateID: req.TemplateID, + Region: req.Region, + Purpose: req.Purpose, + Params: map[string]string{ + "code": code, + }, + } + err = cfg.DB().Transaction(func(tx *gorm.DB) error { + err := tx.Create(smsCode).Error + if err != nil { + return err + } + smsID, err := provider.SendSMS(x.Context(), sendReq) + LogSMS(normalizedPhone, req.Region, provider.GetName(), smsID, err) + if err != nil || smsID == "" { + return fmt.Errorf("failed to send sms: %v", err) + } + return nil + }) + if err != nil { + return nil, err + } + return &SendCodeResponse{ + ID: smsCode.ID, + Phone: smsCode.Phone, + ExpiresAt: smsCode.ExpiresAt, + Interval: int(cfg.Config.SMS.Global.SendInterval.Seconds()), + }, nil +} diff --git a/api/sms/utils.go b/api/sms/utils.go new file mode 100644 index 0000000..ee5bd29 --- /dev/null +++ b/api/sms/utils.go @@ -0,0 +1,90 @@ +// +// utils.go +// Copyright (C) 2025 veypi +// +// Distributed under terms of the MIT license. +// + +package sms + +import ( + "fmt" + "time" + + "github.com/veypi/vbase/cfg" + "github.com/veypi/vbase/models" + "gorm.io/gorm" +) + +// checkDailyLimit 检查每日发送次数限制 +func CheckDailyLimit(phone, region string) error { + today := time.Now().Truncate(24 * time.Hour) + tomorrow := today.Add(24 * time.Hour) + + var count int64 + err := cfg.DB().Model(&models.SMSCode{}). + Where("phone = ? AND region = ? AND created_at >= ? AND created_at < ?", + phone, region, today, tomorrow). + Count(&count).Error + + if err != nil { + return fmt.Errorf("failed to check daily limit: %w", err) + } + + if count >= int64(cfg.Config.SMS.Global.MaxDailyCount) { + return fmt.Errorf("daily sms limit exceeded") + } + + return nil +} + +// checkSendInterval 检查发送间隔 +func CheckSendInterval(phone, region, purpose string) error { + var lastCode models.SMSCode + err := cfg.DB().Where("phone = ? AND region = ? AND purpose = ?", phone, region, purpose). + Order("created_at DESC"). + First(&lastCode).Error + + if err != nil && err != gorm.ErrRecordNotFound { + return fmt.Errorf("failed to check send interval: %w", err) + } + + if err == nil { + timeSinceLastSend := time.Since(lastCode.CreatedAt) + if timeSinceLastSend < cfg.Config.SMS.Global.SendInterval { + remaining := cfg.Config.SMS.Global.SendInterval - timeSinceLastSend + return fmt.Errorf("please wait %d seconds before sending again", int(remaining.Seconds())) + } + } + + return nil +} + +// logSMS 记录短信发送日志 +func LogSMS(phone, region, provider, messageID string, err error) { + log := &models.SMSLog{ + Phone: phone, + Region: region, + Provider: provider, + MessageID: messageID, + Status: "ok", + } + if err != nil { + log.Status = "failed" + log.Error = err.Error() + } + cfg.DB().Create(log) +} + +// CleanupExpiredCodes 清理过期的验证码 +func CleanupExpiredCodes() error { + result := cfg.DB().Model(&models.SMSCode{}). + Where("status = ? AND expires_at < ?", models.CodeStatusPending, time.Now()). + Update("status", models.CodeStatusExpired) + + if result.Error != nil { + return fmt.Errorf("failed to cleanup expired codes: %w", result.Error) + } + + return nil +} diff --git a/api/sms/validate.go b/api/sms/validate.go new file mode 100644 index 0000000..fd12053 --- /dev/null +++ b/api/sms/validate.go @@ -0,0 +1,73 @@ +// +// validate.go +// Copyright (C) 2025 veypi +// +// Distributed under terms of the MIT license. +// + +package sms + +import ( + "fmt" + "time" + + "github.com/veypi/vbase/cfg" + "github.com/veypi/vbase/libs/utils" + "github.com/veypi/vbase/models" + "github.com/veypi/vigo" + "gorm.io/gorm" +) + +// VerifyCode 验证验证码 +func VerifyCode(Phone, Code, Region, Purpose string) error { + normalizedPhone := utils.NormalizePhoneNumber(Phone) + + // 1. 查找最新的待验证码 + var smsCode models.SMSCode + err := cfg.DB().Where("phone = ? AND region = ? AND purpose = ? AND status = ?", + normalizedPhone, Region, Purpose, models.CodeStatusPending). + Order("created_at DESC"). + First(&smsCode).Error + + if err != nil { + if err == gorm.ErrRecordNotFound { + return vigo.NewError("verification code not found or already used").WithCode(404) + } + return vigo.NewError("failed to query sms code").WithError(err) + } + + // 2. 检查验证码是否过期 + if smsCode.IsExpired() { + cfg.DB().Model(&smsCode).Updates(map[string]any{ + "status": models.CodeStatusExpired, + }) + return fmt.Errorf("verification code has expired") + } + + // 3. 检查是否已达到最大尝试次数 + if !smsCode.CanRetry(cfg.Config.SMS.Global.MaxAttempts) { + cfg.DB().Model(&smsCode).Updates(map[string]any{ + "status": models.CodeStatusFailed, + }) + return fmt.Errorf("verification failed too many times") + } + + // 4. 验证码不匹配 + if smsCode.Code != Code { + cfg.DB().Model(&smsCode).Updates(map[string]any{ + "attempts": smsCode.Attempts + 1, + }) + + remaining := cfg.Config.SMS.Global.MaxAttempts - smsCode.Attempts - 1 + return fmt.Errorf("verification code incorrect, %d attempts remaining", remaining) + } + + // 5. 验证成功 + now := time.Now() + cfg.DB().Model(&smsCode).Updates(map[string]any{ + "status": models.CodeStatusUsed, + "used_at": &now, + }) + + return nil +} diff --git a/doc/README.md b/doc/README.md index 9b27b22..9d2795e 100644 --- a/doc/README.md +++ b/doc/README.md @@ -51,7 +51,7 @@ VBase 是一个 **产品化的 IAM + BaaS 平台**,你可以将其**售卖/部 docker-compose up -d # 或使用源码 -go run ./cmd/server +go run ./cli/main.go ``` ### 2. 获取初始管理员密码 diff --git a/doc/architecture.md b/doc/architecture.md index e2261ca..5f40251 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -40,11 +40,11 @@ VBase 是一个**产品化**的解决方案,你可以将 VBase **售卖/部署 #### 目标客户类型 -| 客户类型 | 场景说明 | VBase 提供的价值 | -|----------|----------|-----------------| -| **2C 产品公司** | 社交 App、电商平台、内容社区等 | 用户认证、个人数据管理、权限控制 | -| **2B 产品公司** | SaaS 软件、企业管理系统 | 多租户隔离、组织架构、RBAC 权限 | -| **混合产品公司** | 既有 C 端用户又有 B 端管理 | 统一身份管理、分级权限控制 | +| 客户类型 | 场景说明 | VBase 提供的价值 | +| ---------------- | ------------------------------ | -------------------------------- | +| **2C 产品公司** | 社交 App、电商平台、内容社区等 | 用户认证、个人数据管理、权限控制 | +| **2B 产品公司** | SaaS 软件、企业管理系统 | 多租户隔离、组织架构、RBAC 权限 | +| **混合产品公司** | 既有 C 端用户又有 B 端管理 | 统一身份管理、分级权限控制 | ### 1.2 核心特性 @@ -58,45 +58,49 @@ VBase 是一个**产品化**的解决方案,你可以将 VBase **售卖/部署 ### 1.3 技术栈 -| 层级 | 技术 | -|------|------| -| 框架 | Vigo (Go Web Framework) | -| ORM | GORM | -| 数据库 | MySQL / PostgreSQL / SQLite | -| 缓存 | Redis | -| 认证 | JWT (github.com/golang-jwt/jwt) | -| 密码哈希 | bcrypt | +| 层级 | 技术 | +| -------- | ------------------------------- | +| 框架 | Vigo (Go Web Framework) | +| ORM | GORM | +| 数据库 | MySQL / PostgreSQL / SQLite | +| 缓存 | Redis | +| 认证 | JWT (github.com/golang-jwt/jwt) | +| 密码哈希 | bcrypt | ### 1.4 概念说明 #### 三层级角色体系 -| 层级 | 角色 | 说明 | 权限范围 | -|------|------|------|----------| -| **平台层** | 平台管理员 | VBase 产品提供商(你) | 管理所有 VBase 实例 | -| **实例层** | 实例管理员 | 客户的技术负责人 | 管理自己的 VBase 实例配置 | -| **业务层** | 终端用户 | 客户的 C 端用户或 B 端成员 | 使用客户的产品功能 | +| 层级 | 角色 | 说明 | 权限范围 | +| ---------- | ---------- | -------------------------- | ------------------------- | +| **平台层** | 平台管理员 | VBase 产品提供商(你) | 管理所有 VBase 实例 | +| **实例层** | 实例管理员 | 客户的技术负责人 | 管理自己的 VBase 实例配置 | +| **业务层** | 终端用户 | 客户的 C 端用户或 B 端成员 | 使用客户的产品功能 | #### VBase 实例内部概念 在一个独立的 VBase 实例内部,包含以下核心概念: **用户 (User)**: VBase 实例中的注册用户 + - 可以创建多个项目 - 可以被邀请加入其他项目 - 在不同项目中可以有不同的角色 **项目/组织 (Project/Org)**: 资源管理的基本单元 + - 对 2C 场景:对应一个 App 或产品 - 对 2B 场景:对应一个企业或部门 - 企业可以创建多个项目,也可以使用组织层级管理 **资源 (Resource)**: 项目内的后端资源 + - 数据库、存储、函数、API 等 - 属于特定项目 - 通过项目权限控制访问 **角色 (Role)**: 项目内的权限集合 + - 所有者 (Owner): 项目全部权限 - 管理员 (Admin): 管理项目和成员 - 开发者 (Developer): 读写资源 @@ -181,6 +185,7 @@ VBase 设计为可私有化部署的产品,支持两种交付模式: ``` **特点:** + - 你在自己的云基础设施上为客户部署独立的 VBase 实例 - 每个实例完全隔离(独立数据库、独立 Redis) - 你负责运维,客户只管使用 @@ -208,6 +213,7 @@ VBase 设计为可私有化部署的产品,支持两种交付模式: ``` **特点:** + - 客户在自己的环境中部署 VBase(云服务器、私有云、K8s) - 数据完全属于客户,与你无关 - 客户自己运维,或者购买你的运维支持服务 @@ -217,36 +223,17 @@ VBase 设计为可私有化部署的产品,支持两种交付模式: ### 3.3 两种模式的对比 -| 维度 | 托管部署 | 客户自托管 | -|------|----------|------------| -| **部署位置** | 你的云基础设施 | 客户的基础设施 | -| **数据归属** | 客户拥有,你代管 | 客户完全拥有 | -| **运维责任** | 你负责 | 客户负责 | -| **计费模式** | 按用量/实例数 | 按 License/订阅 | -| **定制化** | 受限 | 完全可定制 | -| **网络要求** | 需要公网访问 | 可内网部署 | - -### 3.4 关键设计:完全隔离 - -无论哪种模式,**每个 VBase 实例都是完全独立的**: - -```go -// 实例级别的完全隔离 -// - 独立的数据库连接 -// - 独立的 Redis 命名空间 -// - 独立的 JWT 密钥 -// - 独立的配置 - -type VBaseInstance struct { - InstanceID string // 实例唯一标识 - DatabaseDSN string // 独立数据库 - RedisPrefix string // Redis 前缀隔离 - JWTSecret string // 独立 JWT 密钥 - Config InstanceConfig -} -``` +| 维度 | 托管部署 | 客户自托管 | +| ------------ | ---------------- | --------------- | +| **部署位置** | 你的云基础设施 | 客户的基础设施 | +| **数据归属** | 客户拥有,你代管 | 客户完全拥有 | +| **运维责任** | 你负责 | 客户负责 | +| **计费模式** | 按用量/实例数 | 按 License/订阅 | +| **定制化** | 受限 | 完全可定制 | +| **网络要求** | 需要公网访问 | 可内网部署 | 这种设计确保: + 1. 客户 A 的数据与客户 B 完全隔离 2. 一个实例的故障不影响其他实例 3. 客户可以独立升级、备份、迁移自己的实例 @@ -289,6 +276,21 @@ vbase/ │ │ ├── revoke.go # 撤销令牌 │ │ ├── oidc.go # OIDC 支持 │ │ └── client.go # 客户端管理 +│ ├── policy/ # 策略管理(RBAC) +│ │ ├── init.go +│ │ ├── list.go +│ │ ├── get.go +│ │ ├── create.go +│ │ ├── patch.go +│ │ └── del.go +│ ├── role/ # 角色管理(RBAC) +│ │ ├── init.go +│ │ ├── list.go +│ │ ├── get.go +│ │ ├── create.go +│ │ ├── patch.go +│ │ ├── del.go +│ │ └── policy.go # 角色策略关联 │ └── middleware/ # 中间件 │ ├── auth.go # JWT 认证 │ ├── org.go # 组织上下文 @@ -326,11 +328,13 @@ vbase/ ### 使用方式 **独立运行:** + ```bash go run cli/main.go ``` **集成到其他项目:** + ```go import "github.com/veypi/vbase" @@ -338,6 +342,43 @@ import "github.com/veypi/vbase" router.Extend("vb", vbase.Router) // 所有 vbase API 将通过 /vb/api/... 访问 ``` +### Vigo 路由规范 + +**父路由挂载子路由模块** - 使用 `Extend`: + +```go +// api/init.go +Router.Extend("/auth", auth.Router) // 将 auth.Router 挂载到 /api/auth +``` + +**同级创建子路由** - 使用 `SubRouter`: + +```go +// api/oauth/init.go +clientRouter := Router.SubRouter("/clients") // 创建 /oauth/clients 子路由 +clientRouter.Get("/", "列表", listClients) +``` + +**跳过中间件** - 使用 `vigo.SkipBefore`: + +```go +// 公开端点跳过认证中间件 +Router.Post("/login", "登录", vigo.SkipBefore, login) +``` + +### 上下文键名 + +中间件设置的上下文键名: + +| 键名 | 类型 | 说明 | +| -------------- | -------------- | --------------------------------- | +| `user_id` | string | 当前用户 ID | +| `user_name` | string | 当前用户名 | +| `user_orgs` | []jwt.OrgClaim | 用户所属组织列表 | +| `token_claims` | \*jwt.Claims | 完整的 JWT 声明 | +| `org_id` | string | 当前组织 ID(如果请求指定了组织) | +| `org_roles` | string | 当前组织角色 ID 列表 | + --- ## 4. 核心模块设计 @@ -401,6 +442,7 @@ VBase 采用三级权限体系: #### 权限模型: RBAC + ABAC **策略模型:** + ```go type Policy struct { Resource string // 资源: user/org/resource/* @@ -461,7 +503,7 @@ type Policy struct { VBase 中的 "Org" 概念灵活适配不同场景: -#### 场景1: 个人项目 (2C) +#### 场景 1: 个人项目 (2C) ``` 个人开发者 @@ -473,7 +515,7 @@ VBase 中的 "Org" 概念灵活适配不同场景: - 资源完全隔离 - 可以邀请其他用户协作 -#### 场景2: 团队协作 (2B) +#### 场景 2: 团队协作 (2B) ``` 创业公司团队 @@ -486,7 +528,7 @@ VBase 中的 "Org" 概念灵活适配不同场景: └── ... ``` -#### 场景3: 企业多层级 (2B Enterprise) +#### 场景 3: 企业多层级 (2B Enterprise) ``` 企业层级 (可选功能) @@ -503,7 +545,7 @@ VBase 中的 "Org" 概念灵活适配不同场景: 以下示例展示 VBase 作为产品交付的完整链路: -#### 示例1: 2C 产品公司(社交 App) +#### 示例 1: 2C 产品公司(社交 App) ``` 你(VBase 提供商) @@ -529,11 +571,12 @@ VBase 中的 "Org" 概念灵活适配不同场景: ``` **关键点:** -- 社交App公司的用户数据完全在自己的 VBase 实例中 + +- 社交 App 公司的用户数据完全在自己的 VBase 实例中 - 你作为 VBase 提供商,接触不到终端用户数据 -- 社交App公司按需购买 License 或订阅服务 +- 社交 App 公司按需购买 License 或订阅服务 -#### 示例2: 2B 产品公司(SaaS 软件) +#### 示例 2: 2B 产品公司(SaaS 软件) ``` 你(VBase 提供商) @@ -557,11 +600,12 @@ VBase 中的 "Org" 概念灵活适配不同场景: ``` **关键点:** + - SaaS 公司用 VBase 做租户隔离(多租户架构) - 你帮 SaaS 公司运维 VBase 实例,按实例数/用户量收费 - SaaS 公司的终端企业用户与你无直接关系 -#### 示例3: 混合产品公司(2C + 2B) +#### 示例 3: 混合产品公司(2C + 2B) ``` 你(VBase 提供商) @@ -584,6 +628,7 @@ VBase 中的 "Org" 概念灵活适配不同场景: ``` **关键点:** + - 同一客户的不同业务线可以用独立 VBase 实例 - 完全物理隔离,符合数据合规要求 - 你提供统一的管理控制台给客户技术团队 @@ -601,12 +646,12 @@ VBase 中的 "Org" 概念灵活适配不同场景: #### 资源类型 -| 资源类型 | 说明 | 典型操作 | -|----------|------|----------| -| **Database** | 数据库表、集合 | 创建表、查询、修改结构、删除表 | -| **Storage** | 文件存储 | 上传、下载、删除、管理存储桶 | -| **Function** | 边缘函数/云函数 | 部署、调用、更新、删除 | -| **API Key** | 访问密钥 | 创建、删除、刷新、权限绑定 | +| 资源类型 | 说明 | 典型操作 | +| ------------ | --------------- | ------------------------------ | +| **Database** | 数据库表、集合 | 创建表、查询、修改结构、删除表 | +| **Storage** | 文件存储 | 上传、下载、删除、管理存储桶 | +| **Function** | 边缘函数/云函数 | 部署、调用、更新、删除 | +| **API Key** | 访问密钥 | 创建、删除、刷新、权限绑定 | #### 资源权限模型 @@ -645,19 +690,19 @@ func AccessResource(userID, orgID, resourceType, action string) error { #### 支持的授权流程 -| 流程 | 说明 | 适用场景 | -|------|------|----------| -| Authorization Code | 授权码模式 | 服务端应用,最安全 | -| Implicit | 简化模式 | SPA/移动端(不推荐) | -| Client Credentials | 客户端凭证 | 服务间调用 | -| Refresh Token | 刷新令牌 | 延长会话 | +| 流程 | 说明 | 适用场景 | +| ------------------ | ---------- | -------------------- | +| Authorization Code | 授权码模式 | 服务端应用,最安全 | +| Implicit | 简化模式 | SPA/移动端(不推荐) | +| Client Credentials | 客户端凭证 | 服务间调用 | +| Refresh Token | 刷新令牌 | 延长会话 | #### 安全特性 - **PKCE**: 防止授权码拦截攻击 - **State**: CSRF 防护 - **Redirect URI 白名单**: 严格匹配 -- **Token 过期**: Access Token 1小时,Refresh Token 30天 +- **Token 过期**: Access Token 1 小时,Refresh Token 30 天 --- @@ -736,22 +781,26 @@ func AccessResource(userID, orgID, resourceType, action string) error { ### 5.2 关键模型说明 **User (用户)** + - 平台级唯一用户账号 - 可同时参与多个项目 - 支持第三方账号绑定 **Org (项目/组织)** + - 资源管理的基本单元 -- 对2C场景: 一个项目 = 一个 Org -- 对2B场景: 可以是项目或企业部门 +- 对 2C 场景: 一个项目 = 一个 Org +- 对 2B 场景: 可以是项目或企业部门 - 支持层级结构(可选) **OrgMember (项目成员)** + - 维护用户与项目的关联 - 记录角色分配 - 支持多种成员状态(待审核、正常、禁用) **Role (角色)** + - 项目内定义的权限集合 - 系统预置角色: owner, admin, developer, viewer - 支持自定义角色 @@ -769,6 +818,7 @@ func AccessResource(userID, orgID, resourceType, action string) error { ### 6.2 请求/响应规范 **成功响应:** + ```json { "code": 200, @@ -778,6 +828,7 @@ func AccessResource(userID, orgID, resourceType, action string) error { ``` **错误响应:** + ```json { "code": 400, diff --git a/doc/integration.md b/doc/integration.md new file mode 100644 index 0000000..339e53e --- /dev/null +++ b/doc/integration.md @@ -0,0 +1,157 @@ +# VBase 集成指南 + +## 1. 引入路由 + +```go +import "github.com/veypi/vbase/api" + +func main() { + // 挂载 vbase 路由到 /api/v1/vb + rootRouter.Extend("/api/v1/vb", api.Router) +} +``` + +## 2. 集成配置 + +配置自动从 vigo 的 config.toml 读取: + +```toml +[vbase] +jwt_secret = "your-secret-key" +jwt_expire = 7200 # token 过期时间(秒) +refresh_expire = 604800 # refresh token 过期时间(秒) +bcrypt_cost = 10 # 密码加密强度 + +[vbase.redis] +addr = "localhost:6379" # 留空或填 memory 使用内存缓存 +password = "" +db = 0 +``` + +或在代码中自定义: + +```go +import "github.com/veypi/vbase/cfg" + +cfg.Config.JWTSecret = "your-secret" +cfg.Config.JWTExpire = 7200 +``` + +## 3. 配置策略 + +创建组织时自动初始化默认策略: + +```go +import "github.com/veypi/vbase/api/middleware" + +// 创建组织后调用 +middleware.InitOrgPolicies(orgID) +``` + +默认创建的策略: +| 策略 | 资源 | 操作 | 条件 | 说明 | +|------|------|------|------|------| +| policy:manage | policy | * | admin | 管理策略 | +| role:manage | role | * | admin | 管理角色 | +| user:update | user | update | owner | 只能改自己 | + +自定义策略: + +```go +import "github.com/veypi/vbase/models" + +policy := &models.Policy{ + Code: "project:delete", + Name: "删除项目", + Resource: "project", + Action: "delete", + Effect: models.PolicyEffectAllow, + Condition: "owner", // 只有所有者能删 + Scope: models.PolicyScopeOrg, +} +cfg.DB().Create(policy) +``` + +## 4. 使用鉴权 + +### 4.1 全局中间件(已内置) + +```go +// api/init.go 已自动配置: +Router.Use(middleware.AuthRequired()) // JWT 认证 +Router.Use(middleware.OrgContext()) // 组织上下文 +``` + +### 4.2 公开接口(跳过认证) + +```go +Router.Get("/public", vigo.SkipBefore, "公开接口", handler) +``` + +### 4.3 接口级权限控制 + +```go +import "github.com/veypi/vbase/api/middleware" + +// 需要管理员权限 +Router.Post("/users", middleware.RequireAdmin(), "创建用户", createUser) + +// 基于 Policy 的细粒度控制 +Router.Post("/projects", middleware.Permission("project", "create"), "创建项目", createProject) + +// 带所有者检查(用户只能改自己的数据) +Router.Patch("/users/{id}", middleware.PermissionWithOwner("user", "update", "owner_id"), "更新用户", updateUser) + +// 管理员或所有者 +Router.Delete("/projects/{id}", middleware.AdminOrOwner("owner_id"), "删除项目", deleteProject) +``` + +### 4.4 代码中手动检查 + +```go +func myHandler(x *vigo.X, req *Req) error { + checker := middleware.NewChecker(x) + + // 检查是否为管理员 + if !checker.IsOrgAdmin() { + return vigo.ErrForbidden + } + + // 检查具体权限 + if err := checker.RequirePermission("resource", "write"); err != nil { + return err + } + + return nil +} +``` + +## 5. 完整示例 + +```go +package main + +import ( + "github.com/veypi/vbase/api" + "github.com/veypi/vbase/api/middleware" + "github.com/veypi/vigo" +) + +func main() { + r := vigo.NewRouter() + + // 1. 挂载 vbase + r.Extend("/api/vb", api.Router) + + // 2. 业务路由加权限 + project := r.SubRouter("/projects") + project.Use(middleware.AuthRequired()) + + project.Get("/", middleware.Permission("project", "list"), "项目列表", listProjects) + project.Post("/", middleware.Permission("project", "create"), "创建项目", createProject) + project.Patch("/{id}", middleware.PermissionWithOwner("project", "update", "owner_id"), "更新项目", updateProject) + project.Delete("/{id}", middleware.AdminOrOwner("owner_id"), "删除项目", deleteProject) + + vigo.Run(r) +} +``` diff --git a/doc/permission.md b/doc/permission.md index e6f13db..b0f3a36 100644 --- a/doc/permission.md +++ b/doc/permission.md @@ -387,23 +387,23 @@ policy := &model.Policy{ Scope: "project", // 项目级策略 OrgID: "project-123", // 所属项目 } -model.DB.Create(policy) +cfg.DB().Create(policy) // 将策略添加到角色 -role := &model.Role{ +role := &models.Role{ OrgID: "project-123", Name: "DBAdmin", PolicyIDs: "custom:db:admin", } -model.DB.Create(role) +cfg.DB().Create(role) // 分配角色给用户 -member := &model.OrgMember{ +member := &models.OrgMember{ OrgID: "project-123", UserID: "user-456", RoleIDs: role.ID, } -model.DB.Create(member) +cfg.DB().Create(member) ``` --- @@ -984,7 +984,7 @@ func ListResources(userID, orgID string) ([]Resource, error) { // 查询该项目的资源(自动隔离) var resources []Resource - err = model.DB.Where("org_id = ?", orgID).Find(&resources).Error + err = cfg.DB().Where("org_id = ?", orgID).Find(&resources).Error return resources, err } ```