Browse Source

init: permgen goctl plugin for permlib permission code generation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
BaiLuoYan 1 week ago
commit
34d2497b83
9 changed files with 543 additions and 0 deletions
  1. 96 0
      README.md
  2. 209 0
      collect.go
  3. 98 0
      generate.go
  4. 14 0
      go.mod
  5. 18 0
      go.sum
  6. 70 0
      input.go
  7. 17 0
      internal/perms/perms.go
  8. 21 0
      main.go
  9. BIN
      permgen

+ 96 - 0
README.md

@@ -0,0 +1,96 @@
+# permgen-for-goctl
+
+goctl 插件,配合 [permlib](https://code.clickto.dev/weiym/permlib) 使用,自动从 go-zero 的 `.api` 文件中提取权限声明,生成 `internal/perms/perms.go`。
+
+## 作用
+
+产品端开发者在 `.api` 文件中通过 `@doc` 和 struct tag 声明权限,permgen 在代码生成时自动:
+
+1. 收集所有接口的 `api:*` 权限 code,并自动生成配对的 `data:*` 权限
+2. 收集请求/响应结构体中的字段级权限(`perm` tag)
+3. 生成路由-权限映射表和字段-权限映射表
+4. 输出 `internal/perms/perms.go`,供 `serviceContext` 注册到 permlib Engine
+
+## 编译
+
+```bash
+git clone https://code.clickto.dev/weiym/permgen-for-goctl.git
+cd permgen-for-goctl
+go build -o permgen .
+```
+
+将编译产物 `permgen` 放到 `PATH` 可访问的目录,例如:
+
+```bash
+mv permgen /usr/local/bin/permgen
+```
+
+## 使用
+
+在产品项目根目录执行:
+
+```bash
+goctl api go -api your.api -dir . --plugin permgen
+```
+
+执行后会在 `internal/perms/perms.go` 生成如下内容:
+
+```go
+// Code generated by permgen. DO NOT EDIT.
+
+package perms
+
+import "code.clickto.dev/weiym/permlib"
+
+var Perms = []permlib.PermDecl{ ... }
+var RoutePerms = []permlib.RoutePermDecl{ ... }
+var FieldPerms = map[string]permlib.FieldPermMap{ ... }
+```
+
+在 `serviceContext` 中注册:
+
+```go
+engine.RegisterPerms(perms.Perms)
+engine.RegisterRoutePerms(perms.RoutePerms)
+engine.RegisterFieldPerms(perms.FieldPerms)
+```
+
+## api 文件写法
+
+### 接口权限
+
+在 `@doc` 中通过 `perm` 字段声明接口权限 code:
+
+```
+@doc (
+    summary: "创建用户"
+    perm:    "api:user:create"
+)
+@handler CreateUser
+post /user/create (CreateUserReq) returns (CreateUserResp)
+```
+
+- 没有 `perm` 声明的接口视为公开接口,不做权限检查
+- `api:user:create` 会自动生成配对的 `data:user:create`
+
+### 字段权限
+
+在请求/响应结构体的字段上通过 `perm` tag 声明字段级权限:
+
+```
+type CreateUserReq {
+    Username string `json:"username"`
+    Email    string `json:"email" perm:"data:user:email:write"`
+    Salary   int64  `json:"salary" perm:"data:user:salary:write"`
+}
+
+type UserListItem {
+    Username string `json:"username"`
+    Email    string `json:"email" perm:"data:user:email:read"`
+    Salary   int64  `json:"salary" perm:"data:user:salary:read"`
+}
+```
+
+- `write` 权限:用户无权时,字段从请求体中移除(或拒绝请求,取决于 `FieldWriteMode`)
+- `read` 权限:用户无权时,字段从响应体中自动移除
+- 没有 `perm` tag 的字段不受控制,始终放行

+ 209 - 0
collect.go

@@ -0,0 +1,209 @@
+package main
+
+import (
+	"fmt"
+	"os"
+	"strings"
+)
+
+type permDecl struct {
+	Code string
+	Name string
+}
+
+type routePermDecl struct {
+	Method   string
+	Path     string
+	PermCode string
+	DataCode string
+}
+
+type fieldPermMap struct {
+	Request  map[string]string // json字段名 → permCode
+	Response map[string]string // json字段名 → permCode
+}
+
+type collectResult struct {
+	perms      []permDecl
+	routePerms []routePermDecl
+	fieldPerms map[string]fieldPermMap // "METHOD /path" → fieldPermMap
+}
+
+func collect(input *PluginInput) *collectResult {
+	seen := make(map[string]bool)
+	result := &collectResult{
+		fieldPerms: make(map[string]fieldPermMap),
+	}
+
+	if input == nil || input.Api == nil {
+		return result
+	}
+
+	// 建立类型名 → TypeDef 的索引,用于展开嵌套类型
+	typeIndex := make(map[string]*TypeDef, len(input.Api.Types))
+	for i := range input.Api.Types {
+		typeIndex[input.Api.Types[i].RawName] = &input.Api.Types[i]
+	}
+
+	addPerm := func(code, name string) {
+		if seen[code] || code == "" {
+			return
+		}
+		seen[code] = true
+		if name == "" {
+			name = generatePermName(code)
+		}
+		result.perms = append(result.perms, permDecl{Code: code, Name: name})
+	}
+
+	for _, group := range input.Api.Service.Groups {
+		for _, route := range group.Routes {
+			method := strings.ToUpper(route.Method)
+			path := route.Path
+			key := method + " " + path
+
+			permCode := ""
+			permName := ""
+			if route.AtDoc.Properties != nil {
+				permCode = route.AtDoc.Properties["perm"]
+				permName = route.AtDoc.Properties["summary"]
+			}
+
+			if permCode == "" {
+				fmt.Fprintf(os.Stderr, "WARN: handler %s (%s %s) has no perm declaration, treated as public\n",
+					route.Handler, method, path)
+				continue
+			}
+
+			addPerm(permCode, permName)
+
+			dataCode := apiToDataCode(permCode)
+			if dataCode != "" {
+				addPerm(dataCode, "")
+			}
+
+			result.routePerms = append(result.routePerms, routePermDecl{
+				Method:   method,
+				Path:     path,
+				PermCode: permCode,
+				DataCode: dataCode,
+			})
+
+			fm := fieldPermMap{
+				Request:  make(map[string]string),
+				Response: make(map[string]string),
+			}
+
+			if route.RequestType != nil {
+				for jsonField, permTag := range extractFieldPermsDeep(route.RequestType, typeIndex) {
+					fm.Request[jsonField] = permTag
+					addPerm(permTag, "")
+				}
+			}
+
+			if route.ResponseType != nil {
+				for jsonField, permTag := range extractFieldPermsDeep(route.ResponseType, typeIndex) {
+					fm.Response[jsonField] = permTag
+					addPerm(permTag, "")
+				}
+			}
+
+			if len(fm.Request) > 0 || len(fm.Response) > 0 {
+				result.fieldPerms[key] = fm
+			}
+		}
+	}
+
+	return result
+}
+
+// extractFieldPermsDeep 递归展开嵌套类型,提取所有 perm tag
+func extractFieldPermsDeep(t *TypeDef, typeIndex map[string]*TypeDef) map[string]string {
+	result := make(map[string]string)
+	if t == nil {
+		return result
+	}
+	collectFieldPerms(t.Members, typeIndex, result)
+	return result
+}
+
+func collectFieldPerms(members []Member, typeIndex map[string]*TypeDef, result map[string]string) {
+	for _, m := range members {
+		jsonName := extractTagValue(m.Tag, "json")
+		if jsonName == "" {
+			jsonName = m.Name
+		}
+
+		permCode := extractTagValue(m.Tag, "perm")
+		if permCode != "" {
+			result[jsonName] = permCode
+			continue
+		}
+
+		// 没有 perm tag,尝试展开嵌套类型(去掉 [] 前缀)
+		rawName := strings.TrimPrefix(m.Type.RawName, "[]")
+		if nested, ok := typeIndex[rawName]; ok && len(nested.Members) > 0 {
+			collectFieldPerms(nested.Members, typeIndex, result)
+		}
+	}
+}
+
+// extractTagValue 从 struct tag 字符串中提取指定 key 的值
+// tag 格式:`json:"username" perm:"data:user:email:write"`
+func extractTagValue(tag, key string) string {
+	tag = strings.Trim(tag, "`")
+	search := key + `:"`
+	idx := strings.Index(tag, search)
+	if idx == -1 {
+		return ""
+	}
+	rest := tag[idx+len(search):]
+	end := strings.Index(rest, `"`)
+	if end == -1 {
+		return ""
+	}
+	val := rest[:end]
+	// 取 json tag 的第一段(去掉 omitempty 等)
+	if key == "json" {
+		val = strings.Split(val, ",")[0]
+		if val == "-" {
+			return ""
+		}
+	}
+	return val
+}
+
+func apiToDataCode(apiCode string) string {
+	if !strings.HasPrefix(apiCode, "api:") {
+		return ""
+	}
+	return "data:" + apiCode[4:]
+}
+
+func generatePermName(code string) string {
+	parts := strings.Split(code, ":")
+	if len(parts) < 2 {
+		return code
+	}
+
+	nameMap := map[string]string{
+		"read":   "读取",
+		"write":  "写入",
+		"create": "创建",
+		"update": "更新",
+		"delete": "删除",
+		"list":   "列表",
+		"detail": "详情",
+	}
+
+	var result []string
+	for i := 1; i < len(parts); i++ {
+		p := parts[i]
+		if mapped, ok := nameMap[p]; ok {
+			result = append(result, mapped)
+		} else {
+			result = append(result, p)
+		}
+	}
+	return strings.Join(result, "-")
+}

+ 98 - 0
generate.go

@@ -0,0 +1,98 @@
+package main
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"text/template"
+)
+
+const permsTpl = `// Code generated by permgen. DO NOT EDIT.
+
+package perms
+
+import "code.clickto.dev/weiym/permlib"
+
+// Perms 权限声明列表,启动时调用 engine.RegisterPerms(Perms) 同步到权限系统
+var Perms = []permlib.PermDecl{
+{{- range .Perms}}
+	{Code: {{printf "%q" .Code}}, Name: {{printf "%q" .Name}}},
+{{- end}}
+}
+
+// RoutePerms 路由-权限映射,启动时调用 engine.RegisterRoutePerms(RoutePerms)
+var RoutePerms = []permlib.RoutePermDecl{
+{{- range .RoutePerms}}
+	{Method: {{printf "%q" .Method}}, Path: {{printf "%q" .Path}}, PermCode: {{printf "%q" .PermCode}}, DataCode: {{printf "%q" .DataCode}}},
+{{- end}}
+}
+
+// FieldPerms 字段权限映射,启动时调用 engine.RegisterFieldPerms(FieldPerms)
+var FieldPerms = map[string]permlib.FieldPermMap{
+{{- range $key, $fm := .FieldPerms}}
+	{{printf "%q" $key}}: {
+		{{- if $fm.Request}}
+		Request: map[string]string{
+			{{- range $field, $code := $fm.Request}}
+			{{printf "%q" $field}}: {{printf "%q" $code}},
+			{{- end}}
+		},
+		{{- end}}
+		{{- if $fm.Response}}
+		Response: map[string]string{
+			{{- range $field, $code := $fm.Response}}
+			{{printf "%q" $field}}: {{printf "%q" $code}},
+			{{- end}}
+		},
+		{{- end}}
+	},
+{{- end}}
+}
+`
+
+type templateData struct {
+	Perms      []permDecl
+	RoutePerms []routePermDecl
+	FieldPerms map[string]fieldPermMap
+}
+
+func generate(result *collectResult, dir string) error {
+	outDir := filepath.Join(dir, "internal", "perms")
+	if err := os.MkdirAll(outDir, 0755); err != nil {
+		return fmt.Errorf("创建目录失败: %w", err)
+	}
+
+	outFile := filepath.Join(outDir, "perms.go")
+
+	tpl, err := template.New("perms").Funcs(template.FuncMap{
+		"printf": fmt.Sprintf,
+	}).Parse(permsTpl)
+	if err != nil {
+		return fmt.Errorf("解析模板失败: %w", err)
+	}
+
+	f, err := os.Create(outFile)
+	if err != nil {
+		return fmt.Errorf("创建文件失败: %w", err)
+	}
+	defer f.Close()
+
+	data := templateData{
+		Perms:      result.perms,
+		RoutePerms: result.routePerms,
+		FieldPerms: result.fieldPerms,
+	}
+
+	if err := tpl.Execute(f, data); err != nil {
+		return fmt.Errorf("生成文件失败: %w", err)
+	}
+
+	fmt.Fprintf(os.Stdout, "permgen: generated %s (%d perms, %d routes, %d field maps)\n",
+		outFile,
+		len(result.perms),
+		len(result.routePerms),
+		len(result.fieldPerms),
+	)
+	return nil
+}
+

+ 14 - 0
go.mod

@@ -0,0 +1,14 @@
+module github.com/xiaozi/permgen
+
+go 1.25.0
+
+require (
+	code.clickto.dev/weiym/permlib v0.1.0 // indirect
+	golang.org/x/net v0.48.0 // indirect
+	golang.org/x/sync v0.20.0 // indirect
+	golang.org/x/sys v0.39.0 // indirect
+	golang.org/x/text v0.32.0 // indirect
+	google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
+	google.golang.org/grpc v1.79.3 // indirect
+	google.golang.org/protobuf v1.36.11 // indirect
+)

+ 18 - 0
go.sum

@@ -0,0 +1,18 @@
+code.clickto.dev/weiym/permlib v0.0.0-20260521035506-4dfb8ab670d6 h1:NrwEnkbhKNuK/NdRi9k/+GGQH2iQ7nEyfDLCs9vYR84=
+code.clickto.dev/weiym/permlib v0.0.0-20260521035506-4dfb8ab670d6/go.mod h1:jZHtlXxVm86UnMquqDW6hJ9qbr5QhnGFCXgiT1trv2g=
+code.clickto.dev/weiym/permlib v0.1.0 h1:eyhs2hY2A4h8yE15n9Awi/EtxDk+LJLGsxxOPqGGL9k=
+code.clickto.dev/weiym/permlib v0.1.0/go.mod h1:jZHtlXxVm86UnMquqDW6hJ9qbr5QhnGFCXgiT1trv2g=
+golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
+golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
+golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
+golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
+google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
+google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
+google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
+google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=

+ 70 - 0
input.go

@@ -0,0 +1,70 @@
+package main
+
+import (
+	"encoding/json"
+	"io"
+	"os"
+)
+
+// goctl 通过 stdin 传入的插件输入结构
+type PluginInput struct {
+	Api         *ApiSpec `json:"Api"`
+	ApiFilePath string   `json:"ApiFilePath"`
+	Style       string   `json:"Style"`
+	Dir         string   `json:"Dir"`
+}
+
+type ApiSpec struct {
+	Types   []TypeDef    `json:"Types"`
+	Service ServiceSpec  `json:"Service"`
+}
+
+type TypeDef struct {
+	RawName string   `json:"RawName"`
+	Members []Member `json:"Members"`
+}
+
+type Member struct {
+	Name string     `json:"Name"`
+	Tag  string     `json:"Tag"`
+	Type MemberType `json:"Type"`
+}
+
+type MemberType struct {
+	RawName string `json:"RawName"`
+}
+
+type ServiceSpec struct {
+	Name   string  `json:"Name"`
+	Groups []Group `json:"Groups"`
+}
+
+type Group struct {
+	Routes []Route `json:"Routes"`
+}
+
+type Route struct {
+	Method       string   `json:"Method"`
+	Path         string   `json:"Path"`
+	Handler      string   `json:"Handler"`
+	RequestType  *TypeDef `json:"RequestType"`
+	ResponseType *TypeDef `json:"ResponseType"`
+	AtDoc        AtDoc    `json:"AtDoc"`
+}
+
+type AtDoc struct {
+	Properties map[string]string `json:"Properties"`
+	Text       string            `json:"Text"`
+}
+
+func readInput() (*PluginInput, error) {
+	data, err := io.ReadAll(os.Stdin)
+	if err != nil {
+		return nil, err
+	}
+	var input PluginInput
+	if err := json.Unmarshal(data, &input); err != nil {
+		return nil, err
+	}
+	return &input, nil
+}

+ 17 - 0
internal/perms/perms.go

@@ -0,0 +1,17 @@
+// Code generated by permgen. DO NOT EDIT.
+
+package perms
+
+import "code.clickto.dev/weiym/permlib"
+
+// Perms 权限声明列表,启动时调用 engine.RegisterPerms(Perms) 同步到权限系统
+var Perms = []permlib.PermDecl{
+}
+
+// RoutePerms 路由-权限映射,启动时调用 engine.RegisterRoutePerms(RoutePerms)
+var RoutePerms = []permlib.RoutePermDecl{
+}
+
+// FieldPerms 字段权限映射,启动时调用 engine.RegisterFieldPerms(FieldPerms)
+var FieldPerms = map[string]permlib.FieldPermMap{
+}

+ 21 - 0
main.go

@@ -0,0 +1,21 @@
+package main
+
+import (
+	"fmt"
+	"os"
+)
+
+func main() {
+	input, err := readInput()
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "permgen: 读取输入失败: %v\n", err)
+		os.Exit(1)
+	}
+
+	result := collect(input)
+
+	if err := generate(result, input.Dir); err != nil {
+		fmt.Fprintf(os.Stderr, "permgen: 生成失败: %v\n", err)
+		os.Exit(1)
+	}
+}

BIN
permgen