diff --git a/client/permissionservice/permission_service.go b/client/permissionservice/permission_service.go index fdae9cb..223681e 100644 --- a/client/permissionservice/permission_service.go +++ b/client/permissionservice/permission_service.go @@ -13,24 +13,41 @@ import ( ) type ( - AuthorizationReq = permission.AuthorizationReq - CancelOneTimeTokenReq = permission.CancelOneTimeTokenReq - CancelTokenReq = permission.CancelTokenReq - CreateOneTimeTokenReq = permission.CreateOneTimeTokenReq - CreateOneTimeTokenResp = permission.CreateOneTimeTokenResp - DoTokenByDeviceIDReq = permission.DoTokenByDeviceIDReq - DoTokenByUIDReq = permission.DoTokenByUIDReq - OKResp = permission.OKResp - QueryTokenByUIDReq = permission.QueryTokenByUIDReq - RefreshTokenReq = permission.RefreshTokenReq - RefreshTokenResp = permission.RefreshTokenResp - Token = permission.Token - TokenResp = permission.TokenResp - Tokens = permission.Tokens - ValidationTokenReq = permission.ValidationTokenReq - ValidationTokenResp = permission.ValidationTokenResp + AuthorizationReq = permission.AuthorizationReq + CancelOneTimeTokenReq = permission.CancelOneTimeTokenReq + CancelTokenReq = permission.CancelTokenReq + CheckPermissionByRoleReq = permission.CheckPermissionByRoleReq + CreateOneTimeTokenReq = permission.CreateOneTimeTokenReq + CreateOneTimeTokenResp = permission.CreateOneTimeTokenResp + DoTokenByDeviceIDReq = permission.DoTokenByDeviceIDReq + DoTokenByUIDReq = permission.DoTokenByUIDReq + GetPermissionStatusByPathReq = permission.GetPermissionStatusByPathReq + ListPermissionResp = permission.ListPermissionResp + ListPermissionStatusResp = permission.ListPermissionStatusResp + MapPermissionStatusResp = permission.MapPermissionStatusResp + NoneReq = permission.NoneReq + OKResp = permission.OKResp + PermissionItem = permission.PermissionItem + PermissionResp = permission.PermissionResp + PermissionStatusItem = permission.PermissionStatusItem + QueryTokenByUIDReq = permission.QueryTokenByUIDReq + RefreshTokenReq = permission.RefreshTokenReq + RefreshTokenResp = permission.RefreshTokenResp + Token = permission.Token + TokenResp = permission.TokenResp + Tokens = permission.Tokens + ValidationTokenReq = permission.ValidationTokenReq + ValidationTokenResp = permission.ValidationTokenResp PermissionService interface { + // ListPermissionStatus 取得所有權限狀態列表,給前端表演用 + ListPermissionStatus(ctx context.Context, in *NoneReq, opts ...grpc.CallOption) (*ListPermissionStatusResp, error) + // MapPermissionStatus 取得所有權限開閉狀態,簡易版,給前端表演用 + MapPermissionStatus(ctx context.Context, in *NoneReq, opts ...grpc.CallOption) (*MapPermissionStatusResp, error) + // CheckPermissionByRole 透過角色 ID 來檢視權限,後台要通過時真的看這個 + CheckPermissionByRole(ctx context.Context, in *CheckPermissionByRoleReq, opts ...grpc.CallOption) (*PermissionResp, error) + // GetPermissionStatusByPath 透過資源拿取角色的狀態 + GetPermissionStatusByPath(ctx context.Context, in *GetPermissionStatusByPathReq, opts ...grpc.CallOption) (*PermissionStatusItem, error) } defaultPermissionService struct { @@ -43,3 +60,27 @@ func NewPermissionService(cli zrpc.Client) PermissionService { cli: cli, } } + +// ListPermissionStatus 取得所有權限狀態列表,給前端表演用 +func (m *defaultPermissionService) ListPermissionStatus(ctx context.Context, in *NoneReq, opts ...grpc.CallOption) (*ListPermissionStatusResp, error) { + client := permission.NewPermissionServiceClient(m.cli.Conn()) + return client.ListPermissionStatus(ctx, in, opts...) +} + +// MapPermissionStatus 取得所有權限開閉狀態,簡易版,給前端表演用 +func (m *defaultPermissionService) MapPermissionStatus(ctx context.Context, in *NoneReq, opts ...grpc.CallOption) (*MapPermissionStatusResp, error) { + client := permission.NewPermissionServiceClient(m.cli.Conn()) + return client.MapPermissionStatus(ctx, in, opts...) +} + +// CheckPermissionByRole 透過角色 ID 來檢視權限,後台要通過時真的看這個 +func (m *defaultPermissionService) CheckPermissionByRole(ctx context.Context, in *CheckPermissionByRoleReq, opts ...grpc.CallOption) (*PermissionResp, error) { + client := permission.NewPermissionServiceClient(m.cli.Conn()) + return client.CheckPermissionByRole(ctx, in, opts...) +} + +// GetPermissionStatusByPath 透過資源拿取角色的狀態 +func (m *defaultPermissionService) GetPermissionStatusByPath(ctx context.Context, in *GetPermissionStatusByPathReq, opts ...grpc.CallOption) (*PermissionStatusItem, error) { + client := permission.NewPermissionServiceClient(m.cli.Conn()) + return client.GetPermissionStatusByPath(ctx, in, opts...) +} diff --git a/client/roleservice/role_service.go b/client/roleservice/role_service.go index 8d7e2e2..c2bbf81 100644 --- a/client/roleservice/role_service.go +++ b/client/roleservice/role_service.go @@ -13,22 +13,31 @@ import ( ) type ( - AuthorizationReq = permission.AuthorizationReq - CancelOneTimeTokenReq = permission.CancelOneTimeTokenReq - CancelTokenReq = permission.CancelTokenReq - CreateOneTimeTokenReq = permission.CreateOneTimeTokenReq - CreateOneTimeTokenResp = permission.CreateOneTimeTokenResp - DoTokenByDeviceIDReq = permission.DoTokenByDeviceIDReq - DoTokenByUIDReq = permission.DoTokenByUIDReq - OKResp = permission.OKResp - QueryTokenByUIDReq = permission.QueryTokenByUIDReq - RefreshTokenReq = permission.RefreshTokenReq - RefreshTokenResp = permission.RefreshTokenResp - Token = permission.Token - TokenResp = permission.TokenResp - Tokens = permission.Tokens - ValidationTokenReq = permission.ValidationTokenReq - ValidationTokenResp = permission.ValidationTokenResp + AuthorizationReq = permission.AuthorizationReq + CancelOneTimeTokenReq = permission.CancelOneTimeTokenReq + CancelTokenReq = permission.CancelTokenReq + CheckPermissionByRoleReq = permission.CheckPermissionByRoleReq + CreateOneTimeTokenReq = permission.CreateOneTimeTokenReq + CreateOneTimeTokenResp = permission.CreateOneTimeTokenResp + DoTokenByDeviceIDReq = permission.DoTokenByDeviceIDReq + DoTokenByUIDReq = permission.DoTokenByUIDReq + GetPermissionStatusByPathReq = permission.GetPermissionStatusByPathReq + ListPermissionResp = permission.ListPermissionResp + ListPermissionStatusResp = permission.ListPermissionStatusResp + MapPermissionStatusResp = permission.MapPermissionStatusResp + NoneReq = permission.NoneReq + OKResp = permission.OKResp + PermissionItem = permission.PermissionItem + PermissionResp = permission.PermissionResp + PermissionStatusItem = permission.PermissionStatusItem + QueryTokenByUIDReq = permission.QueryTokenByUIDReq + RefreshTokenReq = permission.RefreshTokenReq + RefreshTokenResp = permission.RefreshTokenResp + Token = permission.Token + TokenResp = permission.TokenResp + Tokens = permission.Tokens + ValidationTokenReq = permission.ValidationTokenReq + ValidationTokenResp = permission.ValidationTokenResp RoleService interface { Ping(ctx context.Context, in *OKResp, opts ...grpc.CallOption) (*OKResp, error) diff --git a/client/tokenservice/token_service.go b/client/tokenservice/token_service.go index 617dc38..78dc0ac 100644 --- a/client/tokenservice/token_service.go +++ b/client/tokenservice/token_service.go @@ -13,22 +13,31 @@ import ( ) type ( - AuthorizationReq = permission.AuthorizationReq - CancelOneTimeTokenReq = permission.CancelOneTimeTokenReq - CancelTokenReq = permission.CancelTokenReq - CreateOneTimeTokenReq = permission.CreateOneTimeTokenReq - CreateOneTimeTokenResp = permission.CreateOneTimeTokenResp - DoTokenByDeviceIDReq = permission.DoTokenByDeviceIDReq - DoTokenByUIDReq = permission.DoTokenByUIDReq - OKResp = permission.OKResp - QueryTokenByUIDReq = permission.QueryTokenByUIDReq - RefreshTokenReq = permission.RefreshTokenReq - RefreshTokenResp = permission.RefreshTokenResp - Token = permission.Token - TokenResp = permission.TokenResp - Tokens = permission.Tokens - ValidationTokenReq = permission.ValidationTokenReq - ValidationTokenResp = permission.ValidationTokenResp + AuthorizationReq = permission.AuthorizationReq + CancelOneTimeTokenReq = permission.CancelOneTimeTokenReq + CancelTokenReq = permission.CancelTokenReq + CheckPermissionByRoleReq = permission.CheckPermissionByRoleReq + CreateOneTimeTokenReq = permission.CreateOneTimeTokenReq + CreateOneTimeTokenResp = permission.CreateOneTimeTokenResp + DoTokenByDeviceIDReq = permission.DoTokenByDeviceIDReq + DoTokenByUIDReq = permission.DoTokenByUIDReq + GetPermissionStatusByPathReq = permission.GetPermissionStatusByPathReq + ListPermissionResp = permission.ListPermissionResp + ListPermissionStatusResp = permission.ListPermissionStatusResp + MapPermissionStatusResp = permission.MapPermissionStatusResp + NoneReq = permission.NoneReq + OKResp = permission.OKResp + PermissionItem = permission.PermissionItem + PermissionResp = permission.PermissionResp + PermissionStatusItem = permission.PermissionStatusItem + QueryTokenByUIDReq = permission.QueryTokenByUIDReq + RefreshTokenReq = permission.RefreshTokenReq + RefreshTokenResp = permission.RefreshTokenResp + Token = permission.Token + TokenResp = permission.TokenResp + Tokens = permission.Tokens + ValidationTokenReq = permission.ValidationTokenReq + ValidationTokenResp = permission.ValidationTokenResp TokenService interface { // NewToken 建立一個新的 Token,例如:AccessToken diff --git a/etc/permission_example.yaml b/etc/permission_example.yaml index b1f1d42..6c0cd54 100644 --- a/etc/permission_example.yaml +++ b/etc/permission_example.yaml @@ -2,7 +2,7 @@ Name: permission.rpc ListenOn: 0.0.0.0:8080 Etcd: Hosts: - - 127.0.0.1:2379 + - 127.0.0.1:2379 Key: permission.rpc RedisCluster: @@ -10,6 +10,6 @@ RedisCluster: Type: cluster Token: - Expired: 300 - RefreshExpires: 86500 + Expired: 300s + RefreshExpires: 86500s Secret: gg88g88 \ No newline at end of file diff --git a/generate/database/mysql/20240816014305_create_permission_table.down.sql b/generate/database/mysql/20240816014305_create_permission_table.down.sql new file mode 100644 index 0000000..6dc8750 --- /dev/null +++ b/generate/database/mysql/20240816014305_create_permission_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS `permission`; diff --git a/generate/database/mysql/20240816014305_create_permission_table.up.sql b/generate/database/mysql/20240816014305_create_permission_table.up.sql new file mode 100644 index 0000000..cbe4d31 --- /dev/null +++ b/generate/database/mysql/20240816014305_create_permission_table.up.sql @@ -0,0 +1,15 @@ +-- 通常會把整個表都放到記憶體當中,不常搜尋,不需要加其他搜尋的 index +CREATE TABLE `permission` +( + `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT 'PK', + `parent` bigint unsigned DEFAULT NULL, + `name` varchar(255) NOT NULL, + `http_method` varchar(255) NOT NULL, + `http_path` text NOT NULL, + `status` tinyint NOT NULL DEFAULT '1' COMMENT '狀態 1: 啟用, 2: 關閉', + `type` tinyint NOT NULL DEFAULT '1' COMMENT '狀態 1: 後台, 2: 前台', + `create_time` bigint DEFAULT 0 NOT NULL COMMENT '創建時間', + `update_time` bigint DEFAULT 0 NOT NULL COMMENT '更新時間', + PRIMARY KEY (`id`), + UNIQUE KEY `name_unique_key` (`name`) +) ENGINE = InnoDB COMMENT ='權限表'; \ No newline at end of file diff --git a/generate/database/mysql/seeder/20240816014305_init_permission.up.sql b/generate/database/mysql/seeder/20240816014305_init_permission.up.sql new file mode 100644 index 0000000..1293885 --- /dev/null +++ b/generate/database/mysql/seeder/20240816014305_init_permission.up.sql @@ -0,0 +1,15 @@ +-- 一級分類 +INSERT INTO `permission` (`parent`, `name`, `http_method`, `http_path`, `create_time`, `update_time`) +VALUES (0, 'user.info.management', '', '', UNIX_TIMESTAMP(), UNIX_TIMESTAMP()); -- 用戶資訊管理 + +# -- 二級分類 用戶資訊管理 +SET @id := (SELECT id FROM `permission` where name = 'user.info.management'); +INSERT INTO `permission` (`parent`, `name`, `http_method`, `http_path`, `create_time`, `update_time`) +VALUES (@id, 'user.basic.info', '', '', UNIX_TIMESTAMP(), UNIX_TIMESTAMP()); -- 用戶基礎資訊查詢 + + +# -- 三級分類 用戶基礎資訊管理-基礎資訊查詢表 +SET @id := (SELECT id FROM `permission` where name = 'user.basic.info'); +INSERT INTO `permission` (`parent`, `name`, `http_method`, `http_path`, `create_time`, `update_time`) +VALUES (@id, 'user.info.select', 'GET', '/v1/user', UNIX_TIMESTAMP(), UNIX_TIMESTAMP()), -- 查詢 + (@id, 'user.info.select.plain_code', 'GET', '/v1/user', UNIX_TIMESTAMP(), UNIX_TIMESTAMP()); -- 明碼查詢 diff --git a/generate/protobuf/permission.proto b/generate/protobuf/permission.proto index e6ed508..4063650 100644 --- a/generate/protobuf/permission.proto +++ b/generate/protobuf/permission.proto @@ -6,6 +6,8 @@ option go_package="./permission"; // OKResp message OKResp {} +// NoneReq +message NoneReq {} // AuthorizationReq 定義授權請求的結構 message AuthorizationReq { @@ -156,8 +158,77 @@ service TokenService { } +// -------------------------------------------------------------------------------- +enum PermissionStatus { + PERMISSION_STATUS_NONE = 0; // 初始(異常) + PERMISSION_STATUS_OPEN = 1; + PERMISSION_STATUS_CLOSE = 2; +} + + +message PermissionStatusItem { + int64 id =1; + int64 parent_id =2; + string name =3; + PermissionStatus status = 4; + string type =5; + bool approval = 6; +} + +message ListPermissionStatusResp { + repeated PermissionStatusItem data = 1; +} + + +message PermissionItem{ + int64 id = 1; + string name = 2; + string http_method = 3; + string http_path = 4; + optional PermissionItem parent= 5; + repeated PermissionItem children =6; + repeated int64 path_ids=7; +} + +message ListPermissionResp { + repeated PermissionItem data = 1; +} + +message CheckPermissionByRoleReq { + string role = 1; + string path = 2; + string method = 3; +} + +message PermissionResp { + bool allow = 1; + string permission_name =2; + bool plain_code =3; +} + +message GetPermissionStatusByPathReq { + string path = 2; + string method = 3; +} + +message MapPermissionStatusResp { + map data = 1; // permission id : open close +} + +service PermissionService { + // ListPermissionStatus 取得所有權限狀態列表,給前端表演用 + rpc ListPermissionStatus(NoneReq)returns(ListPermissionStatusResp); + // ListPermission 一次性取得所有權限表 + rpc ListPermission(NoneReq)returns(MapPermissionStatusResp); + // CheckPermissionByRole 透過角色 ID 來檢視權限,後台要通過時真的看這個 + rpc CheckPermissionByRole(CheckPermissionByRoleReq)returns(PermissionResp); + // GetPermissionStatusByPath 透過資源拿取角色的狀態 + rpc GetPermissionStatusByPath(GetPermissionStatusByPathReq)returns(PermissionStatusItem); +} + + + service RoleService { rpc Ping(OKResp) returns(OKResp); } -service PermissionService {} \ No newline at end of file diff --git a/go.mod b/go.mod index 25a35bc..3d0f05e 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,8 @@ require ( github.com/go-playground/validator/v10 v10.22.0 github.com/golang-jwt/jwt/v4 v4.5.0 github.com/google/uuid v1.6.0 + github.com/open-policy-agent/opa v0.67.1 + github.com/stretchr/testify v1.9.0 github.com/zeromicro/go-zero v1.7.0 go.uber.org/mock v0.4.0 google.golang.org/grpc v1.65.0 @@ -14,16 +16,20 @@ require ( ) require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/OneOfOne/xxhash v1.2.8 // indirect + github.com/agnivade/levenshtein v1.1.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/coreos/go-semver v0.3.1 // indirect github.com/coreos/go-systemd/v22 v22.5.0 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/emicklei/go-restful/v3 v3.11.0 // indirect github.com/fatih/color v1.17.0 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/go-ini/ini v1.67.0 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.19.6 // indirect @@ -31,12 +37,15 @@ require ( github.com/go-openapi/swag v0.22.4 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-sql-driver/mysql v1.8.1 // indirect + github.com/gobwas/glob v0.2.3 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/mock v1.6.0 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/gnostic-models v0.6.8 // indirect github.com/google/go-cmp v0.6.0 // indirect github.com/google/gofuzz v1.2.0 // indirect + github.com/gorilla/mux v1.8.1 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect @@ -49,25 +58,32 @@ require ( github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/openzipkin/zipkin-go v0.4.3 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_golang v1.19.1 // indirect - github.com/prometheus/client_model v0.5.0 // indirect + github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.48.0 // indirect github.com/prometheus/procfs v0.12.0 // indirect + github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 // indirect github.com/redis/go-redis/v9 v9.6.1 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect + github.com/tchap/go-patricia/v2 v2.3.1 // indirect + github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + github.com/yashtewari/glob-intersection v0.2.0 // indirect go.etcd.io/etcd/api/v3 v3.5.15 // indirect go.etcd.io/etcd/client/pkg/v3 v3.5.15 // indirect go.etcd.io/etcd/client/v3 v3.5.15 // indirect - go.opentelemetry.io/otel v1.24.0 // indirect + go.opentelemetry.io/otel v1.28.0 // indirect go.opentelemetry.io/otel/exporters/jaeger v1.17.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.24.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.28.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 // indirect go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.24.0 // indirect go.opentelemetry.io/otel/exporters/zipkin v1.24.0 // indirect - go.opentelemetry.io/otel/metric v1.24.0 // indirect - go.opentelemetry.io/otel/sdk v1.24.0 // indirect - go.opentelemetry.io/otel/trace v1.24.0 // indirect + go.opentelemetry.io/otel/metric v1.28.0 // indirect + go.opentelemetry.io/otel/sdk v1.28.0 // indirect + go.opentelemetry.io/otel/trace v1.28.0 // indirect go.opentelemetry.io/proto/otlp v1.3.1 // indirect go.uber.org/atomic v1.10.0 // indirect go.uber.org/automaxprocs v1.5.3 // indirect @@ -93,5 +109,5 @@ require ( k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 // indirect sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect - sigs.k8s.io/yaml v1.3.0 // indirect + sigs.k8s.io/yaml v1.4.0 // indirect ) diff --git a/internal/config/config.go b/internal/config/config.go index 6cd58e1..4b3c20b 100755 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,4 +14,8 @@ type Config struct { Expired time.Duration Secret string } + // 加上DB結構體 + DB struct { + DsnString string + } } diff --git a/internal/domain/errors.go b/internal/domain/errors.go index 3182133..ce620d3 100644 --- a/internal/domain/errors.go +++ b/internal/domain/errors.go @@ -13,6 +13,7 @@ import ( // full code 12009 只會有 系統以及錯誤碼,category 是給系統判定用的 // 目前 Scope 以及分類要系統共用,係向的錯誤各自服務實作就好 +// token error 方面 const ( TokenUnexpectedSigningErrorCode = iota + 1 TokenValidateErrorCode @@ -25,6 +26,11 @@ const ( RedisErrorCode ) +const ( + PermissionNotFoundCode = iota + 30 + PermissionGetDataErrorCode +) + // TokenUnexpectedSigningErr 30001 Token 簽名錯誤 func TokenUnexpectedSigningErr(msg string) *ers.Err { mts.AppErrorMetrics.AddFailure("token", "token_unexpected_sign") @@ -63,3 +69,15 @@ func RedisError(msg string) *ers.Err { mts.AppErrorMetrics.AddFailure("redis", "error") return ers.NewErr(code.CloudEPPermission, code.CatInput, RedisErrorCode, msg) } + +// PermissionNotFoundError 30030 權限錯誤 +func PermissionNotFoundError(msg string) *ers.Err { + // 看需要建立哪些 Metrics + return ers.NewErr(code.CloudEPPermission, code.Forbidden, PermissionNotFoundCode, msg) +} + +// PermissionGetDataError 30031 解析權限時錯誤 +func PermissionGetDataError(msg string) *ers.Err { + // 看需要建立哪些 Metrics + return ers.NewErr(code.CloudEPPermission, code.InvalidFormat, PermissionGetDataErrorCode, msg) +} diff --git a/internal/domain/permission.go b/internal/domain/permission.go new file mode 100644 index 0000000..56e152d --- /dev/null +++ b/internal/domain/permission.go @@ -0,0 +1,41 @@ +package domain + +type PermissionType int8 + +const ( + PermissionTypeBackendUser PermissionType = iota + 1 + PermissionTypeFrontendUser +) + +type PermissionTypeCode string + +const ( + PermissionTypeBackCode PermissionTypeCode = "back" + PermissionTypeFrontCode PermissionTypeCode = "front" +) + +var permissionMap = map[int64]PermissionTypeCode{ + 1: PermissionTypeFrontCode, + 2: PermissionTypeBackCode, +} + +func ToPermissionTypeCode(code int64) (PermissionTypeCode, bool) { + result, ok := permissionMap[code] + if !ok { + return "", false + } + + return result, true +} + +func (t *PermissionTypeCode) ToString() string { + return string(*t) +} + +type PermissionStatus string +type Permissions map[string]PermissionStatus + +const ( + PermissionStatusOpenCode PermissionStatus = "open" + PermissionStatusCloseCode PermissionStatus = "close" +) diff --git a/internal/domain/usecase/opa.go b/internal/domain/usecase/opa.go new file mode 100644 index 0000000..1a1f3a1 --- /dev/null +++ b/internal/domain/usecase/opa.go @@ -0,0 +1,47 @@ +package usecase + +import ( + "context" +) + +type OpaUseCase interface { + // CheckRBACPermission 確認有無權限 + CheckRBACPermission(ctx context.Context, req CheckReq) (CheckOPAResp, error) + // LoadPolicy 將 Policy 從其他地方加載到 opa 的 policy 當中 + LoadPolicy(ctx context.Context, input []Policy) error + GetPolicy(ctx context.Context) []map[string]any +} + +type CheckReq struct { + ID string + Roles []string + Path string + Method string +} + +type Grant struct { + ID string + Path string + Method string +} + +type Policy struct { + Methods []string `json:"methods"` + Name string `json:"name"` + Path string `json:"path"` + Role string `json:"role"` +} + +type RuleRequest struct { + Method string `json:"method"` + Path string `json:"path"` + Policies []Policy `json:"policies"` + Roles []string `json:"roles"` +} + +type CheckOPAResp struct { + Allow bool `json:"allow"` + PolicyName string `json:"policy_name"` + PlainCode bool `json:"plain_code"` // 是否為明碼顯示 + Request RuleRequest `json:"request"` +} diff --git a/internal/domain/usecase/permission_tree.go b/internal/domain/usecase/permission_tree.go new file mode 100644 index 0000000..f67f436 --- /dev/null +++ b/internal/domain/usecase/permission_tree.go @@ -0,0 +1,38 @@ +package usecase + +import ( + "ark-permission/internal/domain" + "ark-permission/internal/entity" +) + +// PermissionTreeManager 定義一組操作權限樹的接口 +// 這個名稱說明它是專門負責管理和操作權限樹的管理器 +type PermissionTreeManager interface { + // AddPermission 將一個新的權限節點插入到樹中 + // key 是父節點的ID,value 是要插入的 Permission 資料 + // 此方法應該能處理節點是否存在於父節點下的情況 + AddPermission(parentID int64, permission entity.Permission) error + // FindPermissionByID 根據權限 ID 查詢樹中的某個節點 + // 如果節點存在,返回對應的 Permission 資料,否則返回 nil + FindPermissionByID(permissionID int64) (*Permission, error) + // GetAllParentPermissionIDs 根據傳入的 permissions 列表 + // 找出每個權限的完整父節點權限 ID 路徑 + // 例如,如果 B 的父權限是 A,並且給了 B 權限,則返回 A 和 B 的權限 ID + GetAllParentPermissionIDs(permissions domain.Permissions) ([]int64, error) + // GetAllParentPermissionStatuses 返回給定權限下的所有完整父節點權限狀態 + // 例如,若給 B 權限,該方法將返回所有與 B 相關的父權限的狀態 + GetAllParentPermissionStatuses(permissions domain.Permissions) (domain.Permissions, error) + // GetRolePermissionTree 根據角色權限找出所有父節點和子節點權限狀態 + // 角色權限是傳入的一個列表,該方法會根據每個角色的權限,返回所有相關的權限狀態 + GetRolePermissionTree(rolePermissions []entity.RolePermission) domain.Permissions +} + +type Permission struct { + ID int64 `json:"-"` + Name string `json:"name"` + HTTPMethod string `json:"http_method"` + HTTPPath string `json:"http_path"` + Parent *Permission `json:"-"` + Children []*Permission `json:"children"` + PathIDs []int64 `json:"-"` // full path id +} diff --git a/internal/entity/permission.go b/internal/entity/permission.go new file mode 100644 index 0000000..7268b4c --- /dev/null +++ b/internal/entity/permission.go @@ -0,0 +1,22 @@ +package entity + +import "ark-permission/internal/domain" + +type Permission struct { + ID int64 `gorm:"column:id"` + Parent int64 `gorm:"column:parent"` + + Name string `gorm:"column:name"` + + HTTPMethod string `gorm:"column:http_method"` + HTTPPath string `gorm:"column:http_path"` + + Status int `gorm:"column:status"` + Type domain.PermissionType `gorm:"column:type"` + CreateTime int64 `gorm:"column:create_time;autoCreateTime"` + UpdateTime int64 `gorm:"column:update_time;autoUpdateTime"` +} + +func (c *Permission) TableName() string { + return "permission" +} diff --git a/internal/entity/role_permission.go b/internal/entity/role_permission.go new file mode 100644 index 0000000..3268aae --- /dev/null +++ b/internal/entity/role_permission.go @@ -0,0 +1,14 @@ +package entity + +type RolePermission struct { + ID int64 `gorm:"column:id"` + RoleID int64 `gorm:"column:role_id"` + PermissionID int64 `gorm:"column:permission_id"` + + CreateTime int64 `gorm:"column:create_time;autoCreateTime"` + UpdateTime int64 `gorm:"column:update_time;autoUpdateTime"` +} + +func (c *RolePermission) TableName() string { + return "role_permission" +} diff --git a/internal/lib/middleware/with_context.go b/internal/lib/middleware/with_context.go index df06757..3189821 100644 --- a/internal/lib/middleware/with_context.go +++ b/internal/lib/middleware/with_context.go @@ -1,7 +1,7 @@ package middleware import ( - ers "ark-permission/internal/lib/error" + ers "code.30cm.net/wanderland/library-go/errors" "context" "errors" "time" diff --git a/internal/logic/permissionservice/check_permission_by_role_logic.go b/internal/logic/permissionservice/check_permission_by_role_logic.go new file mode 100644 index 0000000..4dd7f63 --- /dev/null +++ b/internal/logic/permissionservice/check_permission_by_role_logic.go @@ -0,0 +1,59 @@ +package permissionservicelogic + +import ( + "ark-permission/gen_result/pb/permission" + "ark-permission/internal/domain/usecase" + "ark-permission/internal/svc" + ers "code.30cm.net/wanderland/library-go/errors" + "context" + + "github.com/zeromicro/go-zero/core/logx" +) + +type CheckPermissionByRoleLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext + logx.Logger +} + +func NewCheckPermissionByRoleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CheckPermissionByRoleLogic { + return &CheckPermissionByRoleLogic{ + ctx: ctx, + svcCtx: svcCtx, + Logger: logx.WithContext(ctx), + } +} + +type checkPermissionReq struct { + Role string `json:"role" validate:"required"` + Method string `json:"method" validate:"required"` + Path string `json:"path" validate:"required"` +} + +// CheckPermissionByRole 透過角色 ID 來檢視權限 +func (l *CheckPermissionByRoleLogic) CheckPermissionByRole(in *permission.CheckPermissionByRoleReq) (*permission.PermissionResp, error) { + // 驗證所需 + if err := l.svcCtx.Validate.ValidateAll(&checkPermissionReq{ + Role: in.GetRole(), + Method: in.GetMethod(), + Path: in.GetPath(), + }); err != nil { + return nil, ers.InvalidFormat(err.Error()) + } + + rbacPermission, err := l.svcCtx.PolicyAgent.CheckRBACPermission(l.ctx, usecase.CheckReq{ + Roles: []string{in.GetRole()}, + Method: in.GetMethod(), + Path: in.GetPath(), + }) + + if err != nil { + return nil, ers.Forbidden(err.Error()) + } + + return &permission.PermissionResp{ + Allow: rbacPermission.Allow, + PermissionName: rbacPermission.PolicyName, + PlainCode: rbacPermission.PlainCode, + }, nil +} diff --git a/internal/logic/permissionservice/get_permission_status_by_path_logic.go b/internal/logic/permissionservice/get_permission_status_by_path_logic.go new file mode 100644 index 0000000..7eecf8d --- /dev/null +++ b/internal/logic/permissionservice/get_permission_status_by_path_logic.go @@ -0,0 +1,31 @@ +package permissionservicelogic + +import ( + "context" + + "ark-permission/gen_result/pb/permission" + "ark-permission/internal/svc" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetPermissionStatusByPathLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext + logx.Logger +} + +func NewGetPermissionStatusByPathLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetPermissionStatusByPathLogic { + return &GetPermissionStatusByPathLogic{ + ctx: ctx, + svcCtx: svcCtx, + Logger: logx.WithContext(ctx), + } +} + +// GetPermissionStatusByPath 透過資源拿取角色的狀態 +func (l *GetPermissionStatusByPathLogic) GetPermissionStatusByPath(in *permission.GetPermissionStatusByPathReq) (*permission.PermissionStatusItem, error) { + // todo: add your logic here and delete this line + + return &permission.PermissionStatusItem{}, nil +} diff --git a/internal/logic/permissionservice/list_permission_status_logic.go b/internal/logic/permissionservice/list_permission_status_logic.go new file mode 100644 index 0000000..8492c5c --- /dev/null +++ b/internal/logic/permissionservice/list_permission_status_logic.go @@ -0,0 +1,55 @@ +package permissionservicelogic + +import ( + "ark-permission/internal/domain" + ers "code.30cm.net/wanderland/library-go/errors" + "context" + + "ark-permission/gen_result/pb/permission" + "ark-permission/internal/svc" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListPermissionStatusLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext + logx.Logger +} + +func NewListPermissionStatusLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListPermissionStatusLogic { + return &ListPermissionStatusLogic{ + ctx: ctx, + svcCtx: svcCtx, + Logger: logx.WithContext(ctx), + } +} + +// ListPermissionStatus 取得 +func (l *ListPermissionStatusLogic) ListPermissionStatus(in *permission.NoneReq) (*permission.ListPermissionStatusResp, error) { + // 搜尋所有權限 + permissions, err := l.svcCtx.Permission.FindAllOpenPermission(l.ctx) + if err != nil { + return nil, ers.DBError(err.Error()) + } + + exist := make(map[string]struct{}) + status := make([]*permission.PermissionStatusItem, 0, len(permissions)) + for _, v := range permissions { + if _, ok := exist[v.Name]; !ok { + t, _ := domain.ToPermissionTypeCode(v.Type) + status = append(status, &permission.PermissionStatusItem{ + Id: v.Id, // 權限 ID + ParentId: v.Parent.Int64, // 上級權限的ID + Name: v.Name, // 權限名稱 + Status: permission.PermissionStatus(v.Status), // 權限開啟或關閉,判斷時上級權限如果關閉,下級也應該關閉對此人關閉 // TODO 還沒做到,目前忠實呈現 + Type: t.ToString(), // 前台權限,還是後台權限,還是其他中台之類的 + }) + exist[v.Name] = struct{}{} + } + } + + return &permission.ListPermissionStatusResp{ + Data: status, + }, nil +} diff --git a/internal/logic/permissionservice/map_permission_status_logic.go b/internal/logic/permissionservice/map_permission_status_logic.go new file mode 100644 index 0000000..93e148c --- /dev/null +++ b/internal/logic/permissionservice/map_permission_status_logic.go @@ -0,0 +1,31 @@ +package permissionservicelogic + +import ( + "context" + + "ark-permission/gen_result/pb/permission" + "ark-permission/internal/svc" + + "github.com/zeromicro/go-zero/core/logx" +) + +type MapPermissionStatusLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext + logx.Logger +} + +func NewMapPermissionStatusLogic(ctx context.Context, svcCtx *svc.ServiceContext) *MapPermissionStatusLogic { + return &MapPermissionStatusLogic{ + ctx: ctx, + svcCtx: svcCtx, + Logger: logx.WithContext(ctx), + } +} + +// MapPermissionStatus 取得所有權限開閉狀態,簡易版,給前端表演用 +func (l *MapPermissionStatusLogic) MapPermissionStatus(in *permission.NoneReq) (*permission.MapPermissionStatusResp, error) { + // todo: add your logic here and delete this line + + return &permission.MapPermissionStatusResp{}, nil +} diff --git a/internal/model/permission_model.go b/internal/model/permission_model.go new file mode 100755 index 0000000..61361e4 --- /dev/null +++ b/internal/model/permission_model.go @@ -0,0 +1,47 @@ +package model + +import ( + "context" + "errors" + "fmt" + "github.com/zeromicro/go-zero/core/stores/sqlc" + "github.com/zeromicro/go-zero/core/stores/sqlx" +) + +var _ PermissionModel = (*customPermissionModel)(nil) + +type ( + // PermissionModel is an interface to be customized, add more methods here, + // and implement the added methods in customPermissionModel. + PermissionModel interface { + permissionModel + FindAllOpenPermission(ctx context.Context) ([]*Permission, error) + } + + customPermissionModel struct { + *defaultPermissionModel + } +) + +// NewPermissionModel 者裡不用快取版本,因為快取我想要自己控制,也就是 local cache 不想上升至 redis 的層級 +// 因為我 permission 設計是由 sql 新增,重啟服務就可以重啟,或者是未來可以放一個mq 來同步 +func NewPermissionModel(conn sqlx.SqlConn) PermissionModel { + return &customPermissionModel{ + defaultPermissionModel: newPermissionModel(conn), + } +} + +func (m *customPermissionModel) FindAllOpenPermission(ctx context.Context) ([]*Permission, error) { + query := fmt.Sprintf("select %s from %s where `status` = ? order by `create_time` asc", permissionRows, m.table) + var resp []*Permission + err := m.conn.QueryRowsCtx(ctx, &resp, query, 1) + + switch { + case err == nil: + return resp, nil + case errors.Is(err, sqlc.ErrNotFound): + return nil, ErrNotFound + default: + return nil, err + } +} diff --git a/internal/model/permission_model_gen.go b/internal/model/permission_model_gen.go new file mode 100755 index 0000000..ce455e6 --- /dev/null +++ b/internal/model/permission_model_gen.go @@ -0,0 +1,113 @@ +// Code generated by goctl. DO NOT EDIT. + +package model + +import ( + "context" + "database/sql" + "fmt" + "strings" + + "github.com/zeromicro/go-zero/core/stores/builder" + "github.com/zeromicro/go-zero/core/stores/sqlc" + "github.com/zeromicro/go-zero/core/stores/sqlx" + "github.com/zeromicro/go-zero/core/stringx" +) + +var ( + permissionFieldNames = builder.RawFieldNames(&Permission{}) + permissionRows = strings.Join(permissionFieldNames, ",") + permissionRowsExpectAutoSet = strings.Join(stringx.Remove(permissionFieldNames, "`id`"), ",") + permissionRowsWithPlaceHolder = strings.Join(stringx.Remove(permissionFieldNames, "`id`"), "=?,") + "=?" +) + +type ( + permissionModel interface { + Insert(ctx context.Context, data *Permission) (sql.Result, error) + FindOne(ctx context.Context, id int64) (*Permission, error) + FindOneByName(ctx context.Context, name string) (*Permission, error) + Update(ctx context.Context, data *Permission) error + Delete(ctx context.Context, id int64) error + } + + defaultPermissionModel struct { + conn sqlx.SqlConn + table string + } + + Permission struct { + Id int64 `db:"id"` // PK + Parent sql.NullInt64 `db:"parent"` + Name string `db:"name"` + HttpMethod string `db:"http_method"` + HttpPath string `db:"http_path"` + Status int64 `db:"status"` // 狀態 1: 啟用, 2: 關閉 + Type int64 `db:"type"` // 狀態 1: 後台, 2: 前台 + CreateTime int64 `db:"create_time"` // 創建時間 + UpdateTime int64 `db:"update_time"` // 更新時間 + } +) + +func newPermissionModel(conn sqlx.SqlConn) *defaultPermissionModel { + return &defaultPermissionModel{ + conn: conn, + table: "`permission`", + } +} + +func (m *defaultPermissionModel) withSession(session sqlx.Session) *defaultPermissionModel { + return &defaultPermissionModel{ + conn: sqlx.NewSqlConnFromSession(session), + table: "`permission`", + } +} + +func (m *defaultPermissionModel) Delete(ctx context.Context, id int64) error { + query := fmt.Sprintf("delete from %s where `id` = ?", m.table) + _, err := m.conn.ExecCtx(ctx, query, id) + return err +} + +func (m *defaultPermissionModel) FindOne(ctx context.Context, id int64) (*Permission, error) { + query := fmt.Sprintf("select %s from %s where `id` = ? limit 1", permissionRows, m.table) + var resp Permission + err := m.conn.QueryRowCtx(ctx, &resp, query, id) + switch err { + case nil: + return &resp, nil + case sqlc.ErrNotFound: + return nil, ErrNotFound + default: + return nil, err + } +} + +func (m *defaultPermissionModel) FindOneByName(ctx context.Context, name string) (*Permission, error) { + var resp Permission + query := fmt.Sprintf("select %s from %s where `name` = ? limit 1", permissionRows, m.table) + err := m.conn.QueryRowCtx(ctx, &resp, query, name) + switch err { + case nil: + return &resp, nil + case sqlc.ErrNotFound: + return nil, ErrNotFound + default: + return nil, err + } +} + +func (m *defaultPermissionModel) Insert(ctx context.Context, data *Permission) (sql.Result, error) { + query := fmt.Sprintf("insert into %s (%s) values (?, ?, ?, ?, ?, ?, ?, ?)", m.table, permissionRowsExpectAutoSet) + ret, err := m.conn.ExecCtx(ctx, query, data.Parent, data.Name, data.HttpMethod, data.HttpPath, data.Status, data.Type, data.CreateTime, data.UpdateTime) + return ret, err +} + +func (m *defaultPermissionModel) Update(ctx context.Context, newData *Permission) error { + query := fmt.Sprintf("update %s set %s where `id` = ?", m.table, permissionRowsWithPlaceHolder) + _, err := m.conn.ExecCtx(ctx, query, newData.Parent, newData.Name, newData.HttpMethod, newData.HttpPath, newData.Status, newData.Type, newData.CreateTime, newData.UpdateTime, newData.Id) + return err +} + +func (m *defaultPermissionModel) tableName() string { + return m.table +} diff --git a/internal/model/vars.go b/internal/model/vars.go new file mode 100644 index 0000000..69ca814 --- /dev/null +++ b/internal/model/vars.go @@ -0,0 +1,5 @@ +package model + +import "github.com/zeromicro/go-zero/core/stores/sqlx" + +var ErrNotFound = sqlx.ErrNotFound diff --git a/internal/server/permissionservice/permission_service_server.go b/internal/server/permissionservice/permission_service_server.go index d645530..8f89f8f 100644 --- a/internal/server/permissionservice/permission_service_server.go +++ b/internal/server/permissionservice/permission_service_server.go @@ -4,7 +4,10 @@ package server import ( + "context" + "ark-permission/gen_result/pb/permission" + "ark-permission/internal/logic/permissionservice" "ark-permission/internal/svc" ) @@ -18,3 +21,27 @@ func NewPermissionServiceServer(svcCtx *svc.ServiceContext) *PermissionServiceSe svcCtx: svcCtx, } } + +// ListPermissionStatus 取得所有權限狀態列表,給前端表演用 +func (s *PermissionServiceServer) ListPermissionStatus(ctx context.Context, in *permission.NoneReq) (*permission.ListPermissionStatusResp, error) { + l := permissionservicelogic.NewListPermissionStatusLogic(ctx, s.svcCtx) + return l.ListPermissionStatus(in) +} + +// MapPermissionStatus 取得所有權限開閉狀態,簡易版,給前端表演用 +func (s *PermissionServiceServer) MapPermissionStatus(ctx context.Context, in *permission.NoneReq) (*permission.MapPermissionStatusResp, error) { + l := permissionservicelogic.NewMapPermissionStatusLogic(ctx, s.svcCtx) + return l.MapPermissionStatus(in) +} + +// CheckPermissionByRole 透過角色 ID 來檢視權限,後台要通過時真的看這個 +func (s *PermissionServiceServer) CheckPermissionByRole(ctx context.Context, in *permission.CheckPermissionByRoleReq) (*permission.PermissionResp, error) { + l := permissionservicelogic.NewCheckPermissionByRoleLogic(ctx, s.svcCtx) + return l.CheckPermissionByRole(in) +} + +// GetPermissionStatusByPath 透過資源拿取角色的狀態 +func (s *PermissionServiceServer) GetPermissionStatusByPath(ctx context.Context, in *permission.GetPermissionStatusByPathReq) (*permission.PermissionStatusItem, error) { + l := permissionservicelogic.NewGetPermissionStatusByPathLogic(ctx, s.svcCtx) + return l.GetPermissionStatusByPath(in) +} diff --git a/internal/svc/service_context.go b/internal/svc/service_context.go index 2784204..f13e9fe 100644 --- a/internal/svc/service_context.go +++ b/internal/svc/service_context.go @@ -3,11 +3,16 @@ package svc import ( "ark-permission/internal/config" "ark-permission/internal/domain/repository" + domainUseCase "ark-permission/internal/domain/usecase" "ark-permission/internal/lib/required" + "ark-permission/internal/model" repo "ark-permission/internal/repository" + "ark-permission/internal/usecase" ers "code.30cm.net/wanderland/library-go/errors" "code.30cm.net/wanderland/library-go/errors/code" + "context" "github.com/zeromicro/go-zero/core/stores/redis" + "github.com/zeromicro/go-zero/core/stores/sqlx" ) type ServiceContext struct { @@ -16,6 +21,8 @@ type ServiceContext struct { Validate required.Validate Redis redis.Redis TokenRedisRepo repository.TokenRepository + Permission model.PermissionModel + PolicyAgent domainUseCase.OpaUseCase } func NewServiceContext(c config.Config) *ServiceContext { @@ -23,7 +30,19 @@ func NewServiceContext(c config.Config) *ServiceContext { if err != nil { panic(err) } + ers.Scope = code.CloudEPPermission + sqlConn := sqlx.NewMysql(c.DB.DsnString) + + pa, err := usecase.NewOpaUseCase(usecase.OpaUseCaseParam{}) + if err != nil { + panic(err) + } + // TODO policy 權限還要再組合過,我的角度會把 UID 當成一種 RoleID 這樣就可以針對每一個人克制權限,,初期也可以使用最簡安的來做統一,再想一下 + err = pa.LoadPolicy(context.Background(), []domainUseCase.Policy{}) + if err != nil { + panic(err) + } return &ServiceContext{ Config: c, @@ -32,5 +51,7 @@ func NewServiceContext(c config.Config) *ServiceContext { TokenRedisRepo: repo.NewTokenRepository(repo.TokenRepositoryParam{ Store: newRedis, }), + Permission: model.NewPermissionModel(sqlConn), + PolicyAgent: pa, } } diff --git a/internal/usecase/opa.go b/internal/usecase/opa.go new file mode 100644 index 0000000..5e3cd6a --- /dev/null +++ b/internal/usecase/opa.go @@ -0,0 +1,198 @@ +package usecase + +import ( + "ark-permission/internal/domain" + "ark-permission/internal/domain/usecase" + ers "code.30cm.net/wanderland/library-go/errors" + "context" + _ "embed" + "fmt" + "github.com/open-policy-agent/opa/rego" + "github.com/zeromicro/go-zero/core/logx" + "strings" +) + +//go:embed "rule.rego" +var policy []byte + +type OpaUseCaseParam struct{} + +type opaUseCase struct { + // 查詢這個角色是否可用 + allowQuery rego.PreparedEvalQuery + + policies []map[string]any +} + +func (o *opaUseCase) GetPolicy(ctx context.Context) []map[string]any { + return o.policies +} + +func (o *opaUseCase) CheckRBACPermission(ctx context.Context, req usecase.CheckReq) (usecase.CheckOPAResp, error) { + results, err := o.allowQuery.Eval(ctx, rego.EvalInput(map[string]any{ + "roles": req.Roles, + "path": req.Path, + "method": req.Method, + "policies": o.policies, + })) + + if err != nil { + return usecase.CheckOPAResp{}, domain.PermissionGetDataError(fmt.Sprintf("failed to evaluate policy: %v", err)) + } + + if len(results) == 0 { + logx.WithCallerSkip(1).WithFields( + logx.Field("roles", req.Roles), + logx.Field("path", req.Path), + logx.Field("method", req.Method), + logx.Field("policies", o.policies), + ).Error("empty RBAC policy result, possibly due to an incorrect query string or policy") + return usecase.CheckOPAResp{}, domain.PermissionGetDataError("no results returned from policy evaluation") + } + + data, ok := results[0].Expressions[0].Value.(map[string]any) + if !ok { + return usecase.CheckOPAResp{}, domain.PermissionGetDataError("unexpected data format in policy evaluation result") + } + resp, err := convertToCheckOPAResp(data) + if !ok { + return usecase.CheckOPAResp{}, domain.PermissionGetDataError(err.Error()) + } + + return resp, nil +} + +// LoadPolicy 逐一處理 Policy 並且處理超時 +func (o *opaUseCase) LoadPolicy(ctx context.Context, input []usecase.Policy) error { + mapped := make([]map[string]any, 0, len(input)) + + for i, policy := range input { + select { + case <-ctx.Done(): // 監控是否超時或取消 + logx.WithCallerSkip(1).WithFields( + logx.Field("input", input), + ).Error("LoadPolicy context time out") + // TODO 部分完成後處理,記錄日誌並返回成功的部分,或應該要重新 Loading.... + o.policies = append(o.policies, mapped...) + return ers.SystemTimeoutError(fmt.Sprintf("operation timed out after processing %d policies: %v", i, ctx.Err())) + default: + // 繼續處理 + mapped = append(mapped, policiesToMap(policy)) + } + } + + // 完成所有更新後紀錄,整個取代 policies + o.policies = mapped + return nil +} + +func NewOpaUseCase(param OpaUseCaseParam) (usecase.OpaUseCase, error) { + module := rego.Module("policy", string(policy)) + ctx := context.Background() + var allowQueryErr error + uc := &opaUseCase{} + uc.allowQuery, allowQueryErr = rego.New( + rego.Query("data.rbac"), // 要尋找的話 data 必帶, rbac = rego package , allow 是要query 啥 + module, + ).PrepareForEval(ctx) + if allowQueryErr != nil { + return &opaUseCase{}, domain.PermissionGetDataError(allowQueryErr.Error()) + } + + return uc, nil +} + +// 內部使用 +func policiesToMap(policy usecase.Policy) map[string]any { + return map[string]any{ + "methods": policy.Methods, + "name": policy.Name, + "path": policy.Path, + "role": policy.Role, + } +} + +func convertToCheckOPAResp(data map[string]any) (usecase.CheckOPAResp, error) { + var response usecase.CheckOPAResp + + // 解析 allow 欄位 + allow, ok := data["allow"].(bool) + if !ok { + return usecase.CheckOPAResp{}, fmt.Errorf("missing or invalid 'allow' field") + } + response.Allow = allow + + // 解析 policy_name 欄位 + policyData, ok := data["policy_name"].(map[string]any) + if ok { + if name, ok := policyData["name"].(string); ok { + response.PolicyName = name + response.PlainCode = strings.HasSuffix(name, ".plan_code") + } + } else { + return usecase.CheckOPAResp{}, fmt.Errorf("missing or invalid 'policy_name' field") + } + + // 解析 request 欄位 + requestData, ok := data["request"].(map[string]any) + if !ok { + return usecase.CheckOPAResp{}, fmt.Errorf("missing or invalid 'request' field") + } + + // 解析 method 和 path + response.Request.Method, ok = requestData["method"].(string) + if !ok { + return usecase.CheckOPAResp{}, fmt.Errorf("missing or invalid 'method' field") + } + response.Request.Path, ok = requestData["path"].(string) + if !ok { + return usecase.CheckOPAResp{}, fmt.Errorf("missing or invalid 'path' field") + } + + // 解析 policies 欄位 + policiesData, ok := requestData["policies"].([]any) + if !ok { + return usecase.CheckOPAResp{}, fmt.Errorf("missing or invalid 'policies' field") + } + response.Request.Policies = make([]usecase.Policy, len(policiesData)) + for i, policyData := range policiesData { + policyMap, ok := policyData.(map[string]any) + if !ok { + return usecase.CheckOPAResp{}, fmt.Errorf("invalid policy format") + } + // 解析 methods + methodsData, ok := policyMap["methods"].([]any) + if !ok { + return usecase.CheckOPAResp{}, fmt.Errorf("missing or invalid 'methods' field in policy") + } + methods := make([]string, len(methodsData)) + for j, m := range methodsData { + methods[j], ok = m.(string) + if !ok { + return usecase.CheckOPAResp{}, fmt.Errorf("invalid method format in policy") + } + } + // 組裝 policy + response.Request.Policies[i] = usecase.Policy{ + Methods: methods, + Name: policyMap["name"].(string), + Path: policyMap["path"].(string), + Role: policyMap["role"].(string), + } + } + + // 解析 roles 欄位 + rolesData, ok := requestData["roles"].([]any) + if !ok { + return usecase.CheckOPAResp{}, fmt.Errorf("missing or invalid 'roles' field") + } + response.Request.Roles = make([]string, len(rolesData)) + for i, r := range rolesData { + response.Request.Roles[i], ok = r.(string) + if !ok { + return usecase.CheckOPAResp{}, fmt.Errorf("invalid role format") + } + } + + return response, nil +} diff --git a/internal/usecase/opa_test.go b/internal/usecase/opa_test.go new file mode 100644 index 0000000..9fab5c8 --- /dev/null +++ b/internal/usecase/opa_test.go @@ -0,0 +1,332 @@ +package usecase + +import ( + "ark-permission/internal/domain/usecase" + "context" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zeromicro/go-zero/core/logx" + "testing" + "time" +) + +func TestMustOpaUseCase(t *testing.T) { + // 初始化 OPA UseCase + got, err := NewOpaUseCase(OpaUseCaseParam{}) + assert.NoError(t, err) + + ctx := context.Background() + + // 加载 Policy + err = got.LoadPolicy(ctx, []usecase.Policy{ + { + Role: "admin", + Path: "/admin/.*", + Methods: []string{"GET", "POST"}, + Name: "Admin access", + }, + { + Role: "user", + Path: "/user/.*", + Methods: []string{"GET"}, + Name: "User read access", + }, + { + Role: "editor", + Path: "/editor/.*", + Methods: []string{"PUT", "POST"}, + Name: "Editor access", + }, + }) + assert.NoError(t, err) + + // 定義測試表 + tests := []struct { + name string + req usecase.CheckReq + expect bool + expectError bool + }{ + { + name: "單一角色,應允許通過", + req: usecase.CheckReq{ + Roles: []string{"user"}, + Path: "/user/profile", + Method: "GET", + }, + expect: true, + }, + { + name: "多角色其中一個有配到,應允許通過", + req: usecase.CheckReq{ + Roles: []string{"user", "admin"}, + Path: "/user/profile", + Method: "GET", + }, + expect: true, + }, + { + name: "角色不匹配,應拒絕通過", + req: usecase.CheckReq{ + Roles: []string{"editor"}, + Path: "/user/profile", + Method: "GET", + }, + expect: false, + }, + { + name: "路徑不匹配,應拒絕通過", + req: usecase.CheckReq{ + Roles: []string{"user"}, + Path: "/editor/dashboard", + Method: "GET", + }, + expect: false, + }, + { + name: "方法不匹配,應拒絕通過", + req: usecase.CheckReq{ + Roles: []string{"user"}, + Path: "/user/profile", + Method: "POST", + }, + expect: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + check, err := got.CheckRBACPermission(ctx, tt.req) + if tt.expectError { + assert.Error(t, err, "expected an error but got none") + } else { + assert.NoError(t, err, "did not expect an error but got one") + assert.Equal(t, tt.expect, check.Allow) + } + }) + } +} + +func TestLoadPolicy(t *testing.T) { + // 初始化 OPA UseCase + got, err := NewOpaUseCase(OpaUseCaseParam{}) + require.NoError(t, err) + + tests := []struct { + name string + input []usecase.Policy + ctxTimeout time.Duration + expectErr bool + }{ + { + name: "正常加載多個Policy", + input: []usecase.Policy{ + { + Role: "admin", + Path: "/admin/.*", + Methods: []string{"GET", "POST"}, + Name: "Admin access", + }, + { + Role: "user", + Path: "/user/.*", + Methods: []string{"GET"}, + Name: "User read access", + }, + }, + ctxTimeout: 3 * time.Second, // 足夠的時間來執行 + expectErr: false, + }, + { + name: "加載策略超時", + input: []usecase.Policy{ + { + Role: "admin", + Path: "/admin/.*", + Methods: []string{"GET", "POST"}, + Name: "Admin access", + }, + { + Role: "user", + Path: "/user/.*", + Methods: []string{"GET"}, + Name: "User read access", + }, + }, + ctxTimeout: 1 * time.Nanosecond, // 超時 + expectErr: true, + }, + { + name: "空策略加載", + input: []usecase.Policy{}, + ctxTimeout: 3 * time.Second, // 足夠的時間 + expectErr: false, + }, + } + + // 遍歷所有測試用例 + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // 設置具有超時的 Context + ctx, cancel := context.WithTimeout(context.Background(), tt.ctxTimeout) + defer cancel() + + // 調用 LoadPolicy + err := got.LoadPolicy(ctx, tt.input) + + // 檢查是否符合預期錯誤 + if tt.expectErr { + assert.Error(t, err, "預期發生錯誤,但沒有發生") + } else { + assert.NoError(t, err, "不預期發生錯誤,但卻發生了") + } + + // 如果沒有錯誤,檢查 policies 是否被正確加載 + if !tt.expectErr { + assert.Equal(t, len(tt.input), len(got.GetPolicy(ctx)), "policies 加載的數量與輸入數量不一致") + } + }) + } +} + +func BenchmarkLoadPolicy(b *testing.B) { + // 初始化 OPA UseCase + got, _ := NewOpaUseCase(OpaUseCaseParam{}) + logx.Disable() + + policiesSmall := []usecase.Policy{ + { + Role: "admin", + Path: "/admin/.*", + Methods: []string{"GET", "POST"}, + Name: "Admin access", + }, + { + Role: "user", + Path: "/user/.*", + Methods: []string{"GET"}, + Name: "User read access", + }, + } + + policiesLarge := make([]usecase.Policy, 1000) + for i := 0; i < 1000; i++ { + policiesLarge[i] = usecase.Policy{ + Role: "admin", + Path: "/admin/.*", + Methods: []string{"GET", "POST"}, + Name: "Admin access", + } + } + + benchmarks := []struct { + name string + policies []usecase.Policy + ctxTimeout time.Duration + }{ + { + name: "SmallPolicy_NoTimeout", + policies: policiesSmall, + ctxTimeout: 1 * time.Second, // 沒有超時 + }, + { + name: "LargePolicy_NoTimeout", + policies: policiesLarge, + ctxTimeout: 1 * time.Second, // 沒有超時 + }, + { + name: "SmallPolicy_WithTimeout", + policies: policiesSmall, + ctxTimeout: 1 * time.Millisecond, // 沒有超時 + }, + { + name: "LargePolicy_WithTimeout", + policies: policiesLarge, + ctxTimeout: 1 * time.Millisecond, // 沒有超時 + }, + } + + for _, bm := range benchmarks { + b.Run(bm.name, func(b *testing.B) { + for i := 0; i < b.N; i++ { + ctx, cancel := context.WithTimeout(context.Background(), bm.ctxTimeout) + defer cancel() + + _ = got.LoadPolicy(ctx, bm.policies) + } + }) + } +} + +func BenchmarkCheckRBACPermission(b *testing.B) { + got, _ := NewOpaUseCase(OpaUseCaseParam{}) + logx.Disable() + // 定義測試用 Policy + policies := []usecase.Policy{ + { + Role: "admin", + Path: "/admin/.*", + Methods: []string{"GET", "POST"}, + Name: "Admin access", + }, + { + Role: "user", + Path: "/user/.*", + Methods: []string{"GET"}, + Name: "User read access", + }, + { + Role: "editor", + Path: "/editor/.*", + Methods: []string{"PUT", "POST"}, + Name: "Editor access", + }, + } + + // 加載 Policy + _ = got.LoadPolicy(context.Background(), policies) + + // 定義不同測試基準場景 + benchmarks := []struct { + name string + req usecase.CheckReq + }{ + { + name: "SingleRole_SimplePath", + req: usecase.CheckReq{ + Roles: []string{"user"}, + Path: "/user/profile", + Method: "GET", + }, + }, + { + name: "MultipleRoles_ComplexPath", + req: usecase.CheckReq{ + Roles: []string{"admin", "user", "editor"}, + Path: "/editor/dashboard", + Method: "PUT", + }, + }, + { + name: "NoRoles_InvalidPath", + req: usecase.CheckReq{ + Roles: []string{}, + Path: "/invalid/path", + Method: "POST", + }, + }, + } + + // 走訪所有場景 + for _, bm := range benchmarks { + b.Run(bm.name, func(b *testing.B) { + for i := 0; i < b.N; i++ { + // 設置一個超時的 ctx + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond) + defer cancel() + + _, _ = got.CheckRBACPermission(ctx, bm.req) + } + }) + } +} diff --git a/internal/usecase/permission_tree.go b/internal/usecase/permission_tree.go new file mode 100644 index 0000000..6b13315 --- /dev/null +++ b/internal/usecase/permission_tree.go @@ -0,0 +1,300 @@ +package usecase + +import ( + "ark-permission/internal/domain" + "ark-permission/internal/domain/usecase" + "ark-permission/internal/entity" + ers "code.30cm.net/wanderland/library-go/errors" + "fmt" + "sort" +) + +// NewPermissionTree 創建新的權限樹 +func NewPermissionTree() *PermissionTree { + // 插入虛擬跟節點,讓其他人都放在這個底下,行為一致,無需處理邊界 + root := &usecase.Permission{ + ID: 0, // 根節點 ID 通常設為 0 或其他標示符號 + Name: "root", // 虛擬根節點名稱 + Children: []*usecase.Permission{}, + } + + return &PermissionTree{ + root: root, + nodes: map[int64]*usecase.Permission{0: root}, // 根節點也加入 nodes 記錄 + paths: make(map[int64][]int), + names: make(map[string][]int64), + ids: make(map[int64]string), + } +} + +// PermissionTree 將初始化與方法分開 +// 不考慮使用其樹型結構原因是,在我們的的場景下, +// 因為深度不超過 100 層,且資料量在 300 到 500 筆之間,至多不超過一萬筆 +// (考慮到一萬條權限已很複雜) 讀取操作是主要需求,而寫入與刪除操作相對較少, +// 這樣的場景適合空間換時間的策略。 +// 優點: +// 1. 空間換時間: +// • 使用 map 來保存節點和它們的路徑、名稱等,可以在 O(1) 的時間複雜度內查找節點、名稱、路徑等資料,非常適合頻繁讀取的場景。 +// • 雖然消耗了更多的空間(儲存多個映射),但這樣的空間開銷在你的資料量規模(300-500 筆)下是可以接受的。 +// 2. 高效查找: +// • 查找節點(nodes map)、路徑(paths map)和名稱(names map)都是透過直接查找 map 實現的,這會極大提升查找效率,尤其是在讀取大量資料的情況下。 +// • map 在 Golang 中的查找操作具有平均 O(1) 的時間複雜度,非常適合大量查找的場景。 +// 3. 樹結構較小,寫入與刪除頻率較低: +// • 由於你的資料量不大,且寫入和刪除操作較少,這樣的設計不會過度影響性能。即使有少量的寫入或刪除操作,更新 map 的開銷也是可以接受的。 + +// 為何設計 names map[string][]int64 +// 雖然目前sql 層面來說 name 是唯一的 +// 這是因為在某些情況下,權限名稱並不是唯一的,可能會有多個權限使用同一個名稱。例如: +// • 你可能在多個模組中使用相同的權限名稱,例如 “Read” 或 “Write”,但它們的實際權限 ID 是不同的。 +// • 使用 map[string][]int64 結構來儲存這些同名權限,可以快速查找所有對應的權限 ID,並有效管理不同模組的相同名稱權限。 + +type PermissionTree struct { + root *usecase.Permission // 根節點,表示權限樹的最頂層,所有其他權限都從這裡派生 + nodes map[int64]*usecase.Permission // 透過 permission ID 快速查找權限節點,允許 O(1) 查找 + paths map[int64][]int // 每個權限節點的完整路徑,儲存節點 ID 對應的索引路徑,方便快速查找() + names map[string][]int64 // 權限名稱對應權限 ID,支持多個同名權限,允許快速根據名稱查找所有相關 ID + ids map[int64]string // 權限 ID 對應權限名稱,允許根據權限 ID 反向查找權限名稱 +} + +// AddPermission 插入新的權限節點 +func (tree *PermissionTree) AddPermission(parentID int64, value entity.Permission) error { + node := &usecase.Permission{ + ID: value.ID, + Name: value.Name, + HTTPPath: value.HTTPPath, + HTTPMethod: value.HTTPMethod, + Children: []*usecase.Permission{}, + } + + // 查找父節點,找不到回傳錯誤。 + parentNode, err := tree.FindPermissionByID(parentID) + if err != nil { + return ers.ResourceNotFound(err.Error()) + } + + parentNode.Children = append(parentNode.Children, node) + node.Parent = parentNode + + // 設置路徑 ID + if node.Parent.ID >= 0 { + node.PathIDs = append(node.Parent.PathIDs, node.Parent.ID) + } + + // 更新樹結構中的數據 + tree.nodes[value.ID] = node // 節點加入紀錄 + tree.names[value.Name] = append(tree.names[value.Name], value.ID) + tree.ids[value.ID] = value.Name + + // 計算完整路徑 + tree.buildNodePath(node, value.ID) + return nil +} + +// buildNodePath 構建節點的完整路徑 +// paths 是一個 map 結構,儲存每個權限節點的完整路徑 +// key 是節點的權限 ID (int64) +// value 是從根節點到該節點的索引路徑 ([]int), +// 每個 int 值代表該節點在其父節點的 Children 列表中的索引位置。 +// +// 例如: +// 假設有以下的權限樹結構: +// root (ID: 1) +// ├── child_permission_1 (ID: 2) +// │ └── grandchild_permission_1 (ID: 4) +// └── child_permission_2 (ID: 3) +// +// 那麼: +// paths[4] = []int{0, 0} +// 表示 grandchild_permission_1 的完整路徑: +// - 它是根節點的第一個子節點(索引 0) +// - 它的父節點(child_permission_1)也是根節點的第一個子節點(索引 0) +// +// 類似的: +// paths[2] = []int{0} +// 表示 child_permission_1 是根節點的第一個子節點。 +func (tree *PermissionTree) buildNodePath(node *usecase.Permission, insertID int64) { + // 找出該node完整的path路徑 + var path []int + for { + if node.Parent == nil { + sort.SliceStable(path, func(_, _ int) bool { + return true + }) + tree.paths[insertID] = path + + break + } + + for i, v := range node.Parent.Children { + if node.ID == v.ID { + path = append(path, i) + node = node.Parent + } + } + } +} + +// FindPermissionByID 根據 ID 查找權限節點 +// 如果權限節點存在,返回對應的 Permission 資料 +func (tree *PermissionTree) FindPermissionByID(permissionID int64) (*usecase.Permission, error) { + // 直接從 nodes map 中查找對應的節點,O(1) 時間複雜度 + node, ok := tree.nodes[permissionID] + if !ok { + return nil, fmt.Errorf("failed to find ID %d", permissionID) + } + + // 返回找到的節點 + return node, nil +} + +// GetAllParentPermissionIDs 返回所有父節點權限 ID +func (tree *PermissionTree) GetAllParentPermissionIDs(permissions domain.Permissions) ([]int64, error) { + exist := make(map[int64]bool) // 用於記錄已處理的權限 ID + var ids []int64 + + for name, status := range permissions { + if status != domain.PermissionStatusOpenCode { + continue // 只處理開啟狀態的權限 + } + + // 根據名稱查找對應的權限 ID 列表 + pIDs, ok := tree.names[name] + if !ok { + return nil, ers.ResourceNotFound(fmt.Sprintf("permission with name %s not found", name)) + } + + for _, pID := range pIDs { + // 檢查是否已經處理過這個權限 ID + if exist[pID] { + continue + } + + node, err := tree.FindPermissionByID(pID) + if err != nil { + return nil, err + } + + // 如果該節點有子節點且父節點未開啟,則跳過該節點 + if len(node.Children) > 0 && !tree.isParentPermissionOpen(node, permissions) { + continue + } + + // 加入該節點的父節點路徑和自身 ID + ids = append(ids, node.PathIDs...) + ids = append(ids, pID) + + // 將所有相關的 ID 記錄為已處理 + for _, id := range append(node.PathIDs, pID) { + exist[id] = true + } + } + } + + return ids, nil +} + +// GetAllParentPermissionStatuses 返回所有父節點的權限狀態 +// 如果一個權限開啟,返回所有相關父節點的狀態 +func (tree *PermissionTree) GetAllParentPermissionStatuses(permissions domain.Permissions) (domain.Permissions, error) { + // 用來存儲已經處理過的節點,避免重複處理 + exist := make(map[int64]bool) + // 用來存儲返回的所有權限狀態 + resultStatuses := make(domain.Permissions) + + // 遍歷每個權限 + for name, status := range permissions { + // 只處理已開啟的權限 + if status != domain.PermissionStatusOpenCode { + continue + } + + // 根據權限名稱查找對應的權限 ID 列表 + pIDs, ok := tree.names[name] + if !ok { + return nil, ers.ResourceNotFound(fmt.Sprintf("permission with name %s not found", name)) + } + + // 遍歷所有權限 ID + for _, pID := range pIDs { + // 檢查是否已處理過這個 ID + if exist[pID] { + continue + } + + node, err := tree.FindPermissionByID(pID) + if err != nil { + return nil, err + } + + // 處理該節點的父節點路徑和自身 ID + allIDs := append(node.PathIDs, pID) + for _, id := range allIDs { + // 避免重複處理 + if exist[id] { + continue + } + + if permissionName, exists := tree.ids[id]; exists { + // 設置權限狀態為已開啟 + resultStatuses[permissionName] = domain.PermissionStatusOpenCode + exist[id] = true + } + } + } + } + + return resultStatuses, nil +} + +// isParentPermissionOpen 檢查父節點的權限是否已開啟 +func (tree *PermissionTree) isParentPermissionOpen(node *usecase.Permission, permissions domain.Permissions) bool { + for _, child := range node.Children { + // 檢查子節點是否有開啟的權限 + if status, ok := permissions[child.Name]; ok && status == domain.PermissionStatusOpenCode { + return true + } + } + return false +} + +// GetRolePermissionTree 根據角色權限找出所有父節點和子節點權限狀態 +// 角色權限是傳入的一個列表,該方法會根據每個角色的權限,返回所有相關的權限狀態 +func (tree *PermissionTree) GetRolePermissionTree(rolePermissions []entity.RolePermission) (domain.Permissions, error) { + // 初始化用來存儲所有權限狀態的 map + resultStatuses := make(domain.Permissions) + // 初始化用來記錄已處理的權限 ID,避免重複處理 + exist := make(map[int64]bool) + + // 遍歷所有角色的權限 + for _, rolePermission := range rolePermissions { + // 根據權限 ID 找到對應的節點 + node, err := tree.FindPermissionByID(rolePermission.PermissionID) + if err != nil { + return nil, fmt.Errorf("permission with ID %d not found: %w", rolePermission.PermissionID, err) + } + + // 將該節點及其父節點的權限狀態加入 resultStatuses + allIDs := append(node.PathIDs, rolePermission.PermissionID) // 包含所有父節點和當前節點 + for _, id := range allIDs { + if !exist[id] { // 檢查是否已經處理過 + if permissionName, ok := tree.ids[id]; ok { + // 設置權限狀態為已開啟(或者根據 rolePermission 狀態設置) + resultStatuses[permissionName] = domain.PermissionStatusOpenCode + exist[id] = true // 記錄該 ID 已處理過 + } + } + } + + // 遍歷該節點的所有子節點,並設置權限狀態 + for _, child := range node.Children { + if !exist[child.ID] { + if permissionName, ok := tree.ids[child.ID]; ok { + resultStatuses[permissionName] = domain.PermissionStatusOpenCode + exist[child.ID] = true + } + } + } + } + + return resultStatuses, nil +} diff --git a/internal/usecase/permission_tree_test.go b/internal/usecase/permission_tree_test.go new file mode 100644 index 0000000..732b7f5 --- /dev/null +++ b/internal/usecase/permission_tree_test.go @@ -0,0 +1,611 @@ +package usecase + +import ( + "ark-permission/internal/domain" + "ark-permission/internal/domain/usecase" + "ark-permission/internal/entity" + ers "code.30cm.net/wanderland/library-go/errors" + "fmt" + "github.com/stretchr/testify/assert" + "reflect" + "testing" +) + +func TestNewPermissionTree(t *testing.T) { + tests := []struct { + name string + want *PermissionTree + }{ + { + name: "ok", + want: &PermissionTree{ + root: &usecase.Permission{ + ID: 0, + Name: "root", + Children: []*usecase.Permission{}, + }, + nodes: map[int64]*usecase.Permission{0: { + ID: 0, + Name: "root", + Children: []*usecase.Permission{}, + }}, // 根節點也加入 nodes 記錄 + paths: make(map[int64][]int), + names: make(map[string][]int64), + ids: make(map[int64]string), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tree := NewPermissionTree() + // 驗證 Root 的值 + assert.Equal(t, tree.root.ID, tt.want.root.ID) + assert.Equal(t, tree.root.Name, tt.want.root.Name) + assert.Equal(t, len(tree.root.Children), len(tt.want.root.Children)) + + assert.Equal(t, len(tree.nodes), len(tt.want.nodes)) + assert.Equal(t, len(tree.paths), len(tt.want.paths)) + assert.Equal(t, len(tree.ids), len(tt.want.ids)) + + }) + } +} + +// 測試 AddPermission 函數 +func TestAddPermission(t *testing.T) { + tree := NewPermissionTree() + + tests := []struct { + name string + parentID int64 + permission entity.Permission + expectedError error + }{ + { + name: "ok", + parentID: 0, + permission: entity.Permission{ID: 2, Name: "new_permission", HTTPPath: "/new", HTTPMethod: "POST"}, + }, + { + name: "Invalid Parent ID", + parentID: 99, // 無效的 parentID + permission: entity.Permission{ID: 3, Name: "invalid_parent", HTTPPath: "/invalid", HTTPMethod: "GET"}, + expectedError: ers.ResourceNotFound("failed to find ID 99"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tree.AddPermission(tt.parentID, tt.permission) + + // 錯誤檢查 + if !reflect.DeepEqual(err, tt.expectedError) { + t.Errorf("expected error %v, got %v", tt.expectedError, err) + } + + if tt.expectedError == nil { + // 檢查是否已正確插入到 nodes 中 + node, ok := tree.nodes[tt.permission.ID] + if !ok { + t.Errorf("expected permission with ID %d to be in nodes map", tt.permission.ID) + } + + if node.Name != tt.permission.Name { + t.Errorf("expected permission name %s, got %s", tt.permission.Name, node.Name) + } + + // 檢查父節點的子節點是否正確加入 + parentNode, _ := tree.FindPermissionByID(tt.parentID) + found := false + for _, child := range parentNode.Children { + if child.ID == tt.permission.ID { + found = true + break + } + } + if !found { + t.Errorf("expected permission ID %d to be child of parent ID %d", tt.permission.ID, tt.parentID) + } + } + }) + } +} + +// 測試 buildNodePath 函數 +func TestBuildNodePath(t *testing.T) { + // ======== 準備測試 ======== + tree := NewPermissionTree() + // 插入一些節點 + err := tree.AddPermission(0, entity.Permission{ID: 1, Name: "root_permission"}) + assert.NoError(t, err) + err = tree.AddPermission(1, entity.Permission{ID: 2, Name: "child_permission_1"}) + assert.NoError(t, err) + err = tree.AddPermission(1, entity.Permission{ID: 3, Name: "child_permission_2"}) + assert.NoError(t, err) + err = tree.AddPermission(2, entity.Permission{ID: 4, Name: "grandchild_permission"}) + assert.NoError(t, err) + // ======== 準備測試 ======== + + tests := []struct { + name string + nodeID int64 + expectedPath []int + }{ + { + name: "Grandchild Permission Path", + nodeID: 4, + expectedPath: []int{0, 0, 0}, // 根 -> 子 -> 孫節點的索引 + }, + { + name: "Child Permission 1 Path", + nodeID: 2, + expectedPath: []int{0, 0}, // 根 -> 子節點的索引 + }, + { + name: "Child Permission 2 Path", + nodeID: 3, + expectedPath: []int{0, 1}, // 根 -> 子節點的索引 + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // 查找要測試的節點 + node, err := tree.FindPermissionByID(tt.nodeID) + if err != nil { + t.Fatalf("failed to find node with ID %d: %v", tt.nodeID, err) + } + // 構建節點的完整路徑 + // Grandchild Permission Path 測試孫節點的完整路徑,這應該包含根節點和子節點的索引,結果為 [0, 0],表示它是根節點的第一個子節點的第一個子節點。 + // Child Permission 1 Path 測試子節點 1 的路徑,應該是 [0],表示它是根節點的第一個子節點。 + // Child Permission 2 Path 測試子節點 2 的路徑,應該是 [1],表示它是根節點的第二個子節點。 + tree.buildNodePath(node, tt.nodeID) + + // 從 paths 中獲取構建好的路徑 + resultPath, ok := tree.paths[tt.nodeID] + if !ok { + t.Fatalf("path not found for node ID %d", tt.nodeID) + } + + // 比較結果路徑與預期路徑 + if !reflect.DeepEqual(resultPath, tt.expectedPath) { + t.Errorf("expected path %v, got %v", tt.expectedPath, resultPath) + } + }) + } +} + +// 測試 GetAllParentPermissionIDs 函數 +func TestGetAllParentPermissionIDs(t *testing.T) { + tree := NewPermissionTree() + // ======== 準備測試 ======== + // 添加節點 + err := tree.AddPermission(0, entity.Permission{ID: 1, Name: "root_permission"}) + assert.NoError(t, err) + err = tree.AddPermission(1, entity.Permission{ID: 2, Name: "child_permission_1"}) + assert.NoError(t, err) + err = tree.AddPermission(1, entity.Permission{ID: 3, Name: "child_permission_2"}) + assert.NoError(t, err) + err = tree.AddPermission(2, entity.Permission{ID: 4, Name: "grandchild_permission_1"}) + assert.NoError(t, err) + err = tree.AddPermission(3, entity.Permission{ID: 5, Name: "grandchild_permission_2"}) + assert.NoError(t, err) + err = tree.AddPermission(0, entity.Permission{ID: 6, Name: "root_permission_2"}) + assert.NoError(t, err) + err = tree.AddPermission(6, entity.Permission{ID: 6, Name: "child_permission_3"}) + assert.NoError(t, err) + // ======== 準備測試 ======== + + tests := []struct { + name string + permissions domain.Permissions + expectedResult []int64 + expectedError error + }{ + { + name: "Valid permissions with open status", + permissions: domain.Permissions{ + "grandchild_permission_1": domain.PermissionStatusOpenCode, + }, + expectedResult: []int64{0, 1, 2, 4}, // 根 -> 子 -> 孫節點 + expectedError: nil, + }, + { + name: "Valid multiple permissions with open status", + permissions: domain.Permissions{ + "grandchild_permission_1": domain.PermissionStatusOpenCode, + "grandchild_permission_2": domain.PermissionStatusOpenCode, + }, + expectedResult: []int64{0, 1, 2, 4, 0, 1, 3, 5}, // 多個權限 + expectedError: nil, + }, + { + name: "Permission name not found", + permissions: domain.Permissions{ + "unknown_permission": domain.PermissionStatusOpenCode, + }, + expectedResult: nil, + expectedError: fmt.Errorf("permission with name unknown_permission not found"), + }, + { + name: "Permission close by parent node", + permissions: domain.Permissions{ + "root_permission_2": domain.PermissionStatusCloseCode, + }, + expectedResult: nil, + expectedError: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := tree.GetAllParentPermissionIDs(tt.permissions) + + // 檢查返回結果 + if !reflect.DeepEqual(result, tt.expectedResult) { + t.Errorf("expected %v, got %v", tt.expectedResult, result) + } + + // 檢查錯誤 + if (err == nil && tt.expectedError != nil) || (err != nil && tt.expectedError == nil) { + t.Errorf("expected error %v, got %v", tt.expectedError, err) + } + }) + } +} + +// 測試 GetAllParentPermissionStatuses 函數 +func TestGetAllParentPermissionStatuses(t *testing.T) { + tree := NewPermissionTree() + + // 添加節點 + err := tree.AddPermission(0, entity.Permission{ID: 1, Name: "root_permission"}) + assert.NoError(t, err) + err = tree.AddPermission(1, entity.Permission{ID: 2, Name: "child_permission_1"}) + assert.NoError(t, err) + err = tree.AddPermission(1, entity.Permission{ID: 3, Name: "child_permission_2"}) + assert.NoError(t, err) + err = tree.AddPermission(2, entity.Permission{ID: 4, Name: "grandchild_permission_1"}) + assert.NoError(t, err) + err = tree.AddPermission(3, entity.Permission{ID: 5, Name: "grandchild_permission_2"}) + assert.NoError(t, err) + + tests := []struct { + name string + permissions domain.Permissions + expectedResult domain.Permissions + expectedError error + }{ + { + name: "Valid permissions with open status", + permissions: domain.Permissions{ + "grandchild_permission_1": domain.PermissionStatusOpenCode, + }, + expectedResult: domain.Permissions{ + "root_permission": domain.PermissionStatusOpenCode, + "child_permission_1": domain.PermissionStatusOpenCode, + "grandchild_permission_1": domain.PermissionStatusOpenCode, + }, + expectedError: nil, + }, + { + name: "Multiple permissions with open status", + permissions: domain.Permissions{ + "grandchild_permission_1": domain.PermissionStatusOpenCode, + "grandchild_permission_2": domain.PermissionStatusOpenCode, + }, + expectedResult: domain.Permissions{ + "root_permission": domain.PermissionStatusOpenCode, + "child_permission_1": domain.PermissionStatusOpenCode, + "grandchild_permission_1": domain.PermissionStatusOpenCode, + "child_permission_2": domain.PermissionStatusOpenCode, + "grandchild_permission_2": domain.PermissionStatusOpenCode, + }, + expectedError: nil, + }, + { + name: "Permission name not found", + permissions: domain.Permissions{ + "unknown_permission": domain.PermissionStatusOpenCode, + }, + expectedResult: nil, + expectedError: fmt.Errorf("permission with name unknown_permission not found"), + }, + { + name: "Closed permissions are ignored", + permissions: domain.Permissions{ + "grandchild_permission_1": domain.PermissionStatusCloseCode, + }, + expectedResult: domain.Permissions{}, + expectedError: nil, + }, + { + name: "Multiple permissions with close status", + permissions: domain.Permissions{ + "grandchild_permission_1": domain.PermissionStatusCloseCode, + }, + expectedResult: domain.Permissions{}, + expectedError: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := tree.GetAllParentPermissionStatuses(tt.permissions) + + // 檢查返回結果 + if !reflect.DeepEqual(result, tt.expectedResult) { + t.Errorf("expected %v, got %v", tt.expectedResult, result) + } + + // 檢查錯誤 + if (err == nil && tt.expectedError != nil) || (err != nil && tt.expectedError == nil) { + t.Errorf("expected error %v, got %v", tt.expectedError, err) + } + }) + } +} + +// 測試 isParentPermissionOpen 函數 +func TestIsParentPermissionOpen(t *testing.T) { + tree := NewPermissionTree() + + // 添加節點 + err := tree.AddPermission(0, entity.Permission{ID: 1, Name: "root_permission"}) + assert.NoError(t, err) + err = tree.AddPermission(1, entity.Permission{ID: 2, Name: "child_permission_1"}) + assert.NoError(t, err) + err = tree.AddPermission(1, entity.Permission{ID: 3, Name: "child_permission_2"}) + assert.NoError(t, err) + err = tree.AddPermission(2, entity.Permission{ID: 4, Name: "grandchild_permission_1"}) + assert.NoError(t, err) + err = tree.AddPermission(2, entity.Permission{ID: 5, Name: "grandchild_permission_2"}) + assert.NoError(t, err) + + tests := []struct { + name string + nodeID int64 + permissions domain.Permissions + expectedOpen bool + }{ + { + name: "Parent has open child permission", + nodeID: 2, + permissions: domain.Permissions{ + "grandchild_permission_1": domain.PermissionStatusOpenCode, + }, + expectedOpen: true, + }, + { + name: "Parent has no open child permissions", + nodeID: 2, + permissions: domain.Permissions{ + "grandchild_permission_1": domain.PermissionStatusCloseCode, + }, + expectedOpen: false, + }, + { + name: "Parent with multiple children, one open", + nodeID: 2, + permissions: domain.Permissions{ + "grandchild_permission_1": domain.PermissionStatusCloseCode, + "grandchild_permission_2": domain.PermissionStatusOpenCode, + }, + expectedOpen: true, + }, + { + name: "Parent with no child permissions in list", + nodeID: 3, + permissions: domain.Permissions{ + "grandchild_permission_1": domain.PermissionStatusOpenCode, + }, + expectedOpen: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + node, err := tree.FindPermissionByID(tt.nodeID) + if err != nil { + t.Fatalf("failed to find node with ID %d: %v", tt.nodeID, err) + } + + result := tree.isParentPermissionOpen(node, tt.permissions) + if result != tt.expectedOpen { + t.Errorf("expected %v, got %v", tt.expectedOpen, result) + } + }) + } +} + +// 測試 GetRolePermissionTree 函數 +func TestGetRolePermissionTree(t *testing.T) { + tree := NewPermissionTree() + + // 添加節點 + err := tree.AddPermission(0, entity.Permission{ID: 1, Name: "root_permission"}) + assert.NoError(t, err) + err = tree.AddPermission(1, entity.Permission{ID: 2, Name: "child_permission_1"}) + assert.NoError(t, err) + err = tree.AddPermission(1, entity.Permission{ID: 3, Name: "child_permission_2"}) + assert.NoError(t, err) + err = tree.AddPermission(2, entity.Permission{ID: 4, Name: "grandchild_permission_1"}) + assert.NoError(t, err) + err = tree.AddPermission(3, entity.Permission{ID: 5, Name: "grandchild_permission_2"}) + assert.NoError(t, err) + + // 測試數據 + tests := []struct { + name string + rolePermissions []entity.RolePermission + expectedResult domain.Permissions + expectedError error + }{ + { + name: "Single role permission with parent and child", + rolePermissions: []entity.RolePermission{ + {PermissionID: 4}, // grandchild_permission_1 + }, + expectedResult: domain.Permissions{ + "root_permission": domain.PermissionStatusOpenCode, + "child_permission_1": domain.PermissionStatusOpenCode, + "grandchild_permission_1": domain.PermissionStatusOpenCode, + }, + expectedError: nil, + }, + { + name: "Multiple role permissions with parents and children", + rolePermissions: []entity.RolePermission{ + {PermissionID: 4}, // grandchild_permission_1 + {PermissionID: 5}, // grandchild_permission_2 + }, + expectedResult: domain.Permissions{ + "root_permission": domain.PermissionStatusOpenCode, + "child_permission_1": domain.PermissionStatusOpenCode, + "grandchild_permission_1": domain.PermissionStatusOpenCode, + "child_permission_2": domain.PermissionStatusOpenCode, + "grandchild_permission_2": domain.PermissionStatusOpenCode, + }, + expectedError: nil, + }, + { + name: "Role permission with no children", + rolePermissions: []entity.RolePermission{ + {PermissionID: 2}, // child_permission_1 + }, + expectedResult: domain.Permissions{ + "root_permission": domain.PermissionStatusOpenCode, + "child_permission_1": domain.PermissionStatusOpenCode, + "grandchild_permission_1": domain.PermissionStatusOpenCode, + }, + expectedError: nil, + }, + { + name: "Role permission not found", + rolePermissions: []entity.RolePermission{ + {PermissionID: 99}, // non-existent permission + }, + expectedResult: nil, + expectedError: fmt.Errorf("permission with ID %d not found", 99), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := tree.GetRolePermissionTree(tt.rolePermissions) + + // 檢查返回結果 + if !reflect.DeepEqual(result, tt.expectedResult) { + t.Errorf("expected %v, got %v", tt.expectedResult, result) + } + + // 檢查錯誤 + if (err == nil && tt.expectedError != nil) || (err != nil && tt.expectedError == nil) { + t.Errorf("expected error %v, got %v", tt.expectedError, err) + } + }) + } +} + +func BenchmarkGetAllParentPermissionIDs(b *testing.B) { + tree := NewPermissionTree() + + // 添加節點 + tree.AddPermission(0, entity.Permission{ID: 1, Name: "root_permission"}) + tree.AddPermission(1, entity.Permission{ID: 2, Name: "child_permission_1"}) + tree.AddPermission(1, entity.Permission{ID: 3, Name: "child_permission_2"}) + tree.AddPermission(2, entity.Permission{ID: 4, Name: "grandchild_permission_1"}) + tree.AddPermission(3, entity.Permission{ID: 5, Name: "grandchild_permission_2"}) + + permissions := domain.Permissions{ + "grandchild_permission_1": domain.PermissionStatusOpenCode, + "grandchild_permission_2": domain.PermissionStatusOpenCode, + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = tree.GetAllParentPermissionIDs(permissions) + } +} + +func BenchmarkGetAllParentPermissionStatuses(b *testing.B) { + tree := NewPermissionTree() + + // 添加節點 + tree.AddPermission(0, entity.Permission{ID: 1, Name: "root_permission"}) + tree.AddPermission(1, entity.Permission{ID: 2, Name: "child_permission_1"}) + tree.AddPermission(1, entity.Permission{ID: 3, Name: "child_permission_2"}) + tree.AddPermission(2, entity.Permission{ID: 4, Name: "grandchild_permission_1"}) + tree.AddPermission(3, entity.Permission{ID: 5, Name: "grandchild_permission_2"}) + + permissions := domain.Permissions{ + "grandchild_permission_1": domain.PermissionStatusOpenCode, + "grandchild_permission_2": domain.PermissionStatusOpenCode, + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = tree.GetAllParentPermissionStatuses(permissions) + } +} + +func BenchmarkIsParentPermissionOpen(b *testing.B) { + tree := NewPermissionTree() + + // 添加節點 + tree.AddPermission(0, entity.Permission{ID: 1, Name: "root_permission"}) + tree.AddPermission(1, entity.Permission{ID: 2, Name: "child_permission_1"}) + tree.AddPermission(1, entity.Permission{ID: 3, Name: "child_permission_2"}) + tree.AddPermission(2, entity.Permission{ID: 4, Name: "grandchild_permission_1"}) + tree.AddPermission(2, entity.Permission{ID: 5, Name: "grandchild_permission_2"}) + + permissions := domain.Permissions{ + "grandchild_permission_1": domain.PermissionStatusOpenCode, + "grandchild_permission_2": domain.PermissionStatusCloseCode, + } + + node, _ := tree.FindPermissionByID(2) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = tree.isParentPermissionOpen(node, permissions) + } +} + +func BenchmarkGetRolePermissionTree(b *testing.B) { + tree := NewPermissionTree() + + // 添加節點 + tree.AddPermission(0, entity.Permission{ID: 1, Name: "root_permission"}) + tree.AddPermission(1, entity.Permission{ID: 2, Name: "child_permission_1"}) + tree.AddPermission(1, entity.Permission{ID: 3, Name: "child_permission_2"}) + tree.AddPermission(2, entity.Permission{ID: 4, Name: "grandchild_permission_1"}) + tree.AddPermission(3, entity.Permission{ID: 5, Name: "grandchild_permission_2"}) + + rolePermissions := []entity.RolePermission{ + {PermissionID: 4}, // grandchild_permission_1 + {PermissionID: 5}, // grandchild_permission_2 + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = tree.GetRolePermissionTree(rolePermissions) + } +} + +func BenchmarkBuildNodePath(b *testing.B) { + tree := NewPermissionTree() + + // 添加節點 + tree.AddPermission(0, entity.Permission{ID: 1, Name: "root_permission"}) + tree.AddPermission(1, entity.Permission{ID: 2, Name: "child_permission_1"}) + tree.AddPermission(1, entity.Permission{ID: 3, Name: "child_permission_2"}) + tree.AddPermission(2, entity.Permission{ID: 4, Name: "grandchild_permission_1"}) + + node, _ := tree.FindPermissionByID(4) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + tree.buildNodePath(node, 4) + } +} diff --git a/internal/usecase/rule.rego b/internal/usecase/rule.rego new file mode 100644 index 0000000..5058a00 --- /dev/null +++ b/internal/usecase/rule.rego @@ -0,0 +1,43 @@ +package rbac + +import rego.v1 + +request = { + "roles": input.roles, + "path": input.path, + "method": input.method, + "policies": input.policies, +} + +default allow = false + +key_match(request_path, policy_path) if { + regex.match(policy_path, request_path) +} + +# 方法函數的驗證 +method_match(request_method, policy_methods) if { + policy_methods[_] == request_method +} + +# 檢驗是不是匹配或繼承 +valid_role(user_role, policy_role) if { + user_role[_] == policy_role +} + +# 定義一個策略 +allow if { + policy := input.policies[_] + key_match(input.path, policy.path) + valid_role(input.roles, policy.role) + method_match(input.method, policy.methods) +} + +# 返回當前符合的策略名稱 +policy_name := { + "name": policy.name| + policy := input.policies[_] + key_match(input.path, policy.path); + valid_role(input.roles, policy.role); + method_match(input.method, policy.methods) +} \ No newline at end of file