From bdeb7e826390a76313e00823022fe269d4524136 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Thu, 21 May 2026 17:30:50 +0800 Subject: [PATCH] refactor(middleware): wire AuthJWT + CasbinRBAC via .api middleware directive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop relying on a global server.Use(CloudEPJWT) that was invisible from the .api source. Protected routes now declare middleware explicitly in each @server block and goctl chains them into routes.go — the .api file is the single source of truth for "who needs Bearer / who needs RBAC". Concretely: - Rewrite middleware to go-zero's standard struct + Handle() pattern. AuthJWT becomes strict: missing/invalid Bearer returns 28501000 (was soft passthrough). CasbinRBAC stays nil-tolerant so dev/test boots without a policy. - Files renamed to goctl's stringx convention (authjwt_middleware.go, casbinrbac_middleware.go) so future `make gen-api` runs see them as already-generated and skip the empty stub. - Move actor context helpers (Actor, WithActor, ActorFromContext) into internal/library/actor so middleware and BOTH logic packages share one context key. Previously each logic package had its own private actorKey struct{}, so an actor injected for member was invisible to permission — the permission RBAC chain would always see "missing actor". member/permission actor.go are now thin type-alias shims. - .api files declare middleware per group: auth.api (public) → no middleware (register/login/token/...) auth.api (logout) → middleware: AuthJWT member.api → middleware: AuthJWT permission.api (catalog,me) → middleware: AuthJWT permission.api (admin ops) → middleware: AuthJWT,CasbinRBAC normal.api (/health) → no middleware - ServiceContext exposes AuthJWT / CasbinRBAC as rest.Middleware; the global server.Use(...) in gateway.go is removed. - Document the pattern in AGENTS.md (cross-agent rules) and generate/api/README.md (detailed examples + filename rules) so any future AI agent or human follows the same convention. make gen-api / gen-doc / lint / build all pass. Co-authored-by: Cursor --- AGENTS.md | 23 ++ gateway.go | 6 +- generate/api/README.md | 106 ++++++ generate/api/auth.api | 13 +- generate/api/member.api | 9 +- generate/api/permission.api | 18 +- internal/handler/routes.go | 368 ++++++++++--------- internal/library/actor/actor.go | 45 +++ internal/logic/member/actor.go | 25 +- internal/logic/permission/actor.go | 28 +- internal/middleware/auth_jwt.go | 47 --- internal/middleware/authjwt_middleware.go | 77 ++++ internal/middleware/casbin_rbac.go | 92 ----- internal/middleware/casbinrbac_middleware.go | 96 +++++ internal/svc/service_context.go | 16 + 15 files changed, 610 insertions(+), 359 deletions(-) create mode 100644 internal/library/actor/actor.go delete mode 100644 internal/middleware/auth_jwt.go create mode 100644 internal/middleware/authjwt_middleware.go delete mode 100644 internal/middleware/casbin_rbac.go create mode 100644 internal/middleware/casbinrbac_middleware.go diff --git a/AGENTS.md b/AGENTS.md index ca56fe5..6a85302 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,6 +42,29 @@ - Scope 註冊在 `internal/library/errors/code/types.go`(Facade=10, Auth=28, Member=29, Notification=30, Permission=31) - 新增 scope 時:同步更新 `gateway.api` 的 `bizCodeEnumDescription` +### 4. Middleware(go-zero 正規手段) + +**禁止**在 `gateway.go` 用 `server.Use(...)` 全域掛 middleware,**所有** middleware 都透過 `.api` 的 `middleware:` 宣告: + +```go +@server ( + group: auth + prefix: /api/v1/auth + middleware: AuthJWT // 一個 + // middleware: AuthJWT,CasbinRBAC // 多個用逗號 +) +``` + +跑 `make gen-api` 後 `routes.go` 會自動 `rest.WithMiddlewares([]rest.Middleware{serverCtx.AuthJWT}, ...)`。 + +撰寫新 middleware 時: +- 用 **struct + `Handle()` method** 模式(不是 factory function) +- 檔名 = goctl stringx 規則(例 `AuthJWT` → `authjwt_middleware.go`、`CasbinRBAC` → `casbinrbac_middleware.go`) +- 在 `ServiceContext` 加 ` rest.Middleware` 欄位,於 `NewServiceContext` 結尾 wire `sc. = middleware.NewMiddleware(...).Handle` +- Actor context 一律用 `internal/library/actor`(`WithActor` / `ActorFromContext`),禁止各 package 自定 `actorKey struct{}`(會造成 context value 進不來) + +詳細範例與分組原則見 [`generate/api/README.md`](generate/api/README.md) "Middleware" 章節。 + ### 4. Redis / Mongo / 設定 - 每個模組的設定型別放 `internal/model//config/config.go`,再合入 `internal/config/config.go` 的 `Config` struct diff --git a/gateway.go b/gateway.go index 2d1798c..fa05cc2 100644 --- a/gateway.go +++ b/gateway.go @@ -14,7 +14,6 @@ import ( "gateway/internal/config" "gateway/internal/handler" "gateway/internal/library/errors/code" - "gateway/internal/middleware" "gateway/internal/response" "gateway/internal/svc" @@ -36,9 +35,8 @@ func main() { defer server.Stop() sc := svc.NewServiceContext(c) - if sc.AuthToken != nil { - server.Use(middleware.CloudEPJWT(sc.AuthToken)) - } + // Middlewares are now mounted per route group via .api `middleware:` + // directives (AuthJWT / CasbinRBAC). See generate/api/README.md. handler.RegisterHandlers(server, sc) workerCtx, stopWorkers := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) diff --git a/generate/api/README.md b/generate/api/README.md index 61624c5..aeb3b2f 100644 --- a/generate/api/README.md +++ b/generate/api/README.md @@ -94,6 +94,112 @@ go build ./... # 確保編譯通過 驗證:把 `docs/openapi/gateway.yaml` 丟進 https://editor.swagger.io 或 Swagger UI,確認分組、description、enum 都正確顯示。 +## Middleware(go-zero 正規手段) + +**強制原則**:所有需要登入 / 授權的 endpoint 都用 `.api` 的 `middleware:` 宣告,**不要**在 `gateway.go` 用 `server.Use(...)` 全域掛載。 + +### 設定方式 + +在 `@server` 加 `middleware: AuthJWT` 或 `middleware: AuthJWT,CasbinRBAC`(多個用逗號): + +```go +// 公開(不掛)— 註冊 / 登入 / token refresh / health +@server ( + group: auth + prefix: /api/v1/auth + tags: "Auth - 認證(公開)" +) +service gateway { ... register / login / token/exchange / token/refresh ... } + +// 需登入(AuthJWT)— logout / member 自身資料 / Permission catalog & me +@server ( + group: auth + prefix: /api/v1/auth + middleware: AuthJWT + tags: "Auth - 認證(需 Bearer)" +) +service gateway { ... logout ... } + +// 需登入 + RBAC(AuthJWT,CasbinRBAC)— Permission 管理類 endpoint +@server ( + group: permission + prefix: /api/v1/permissions + middleware: AuthJWT,CasbinRBAC + tags: "Permission - 權限(管理)" +) +service gateway { ... roles / role-mappings / users / policy/reload ... } +``` + +跑 `make gen-api` 後 `internal/handler/routes.go` 會自動產生: + +```go +server.AddRoutes( + rest.WithMiddlewares( + []rest.Middleware{serverCtx.AuthJWT, serverCtx.CasbinRBAC}, + []rest.Route{ ... }..., + ), + rest.WithPrefix("/api/v1/permissions"), +) +``` + +### 撰寫新 middleware(go-zero 標準 struct 模式) + +每個 middleware **必須**是 struct + `NewXxxMiddleware(...)` 建構函式 + `Handle(next http.HandlerFunc) http.HandlerFunc` 方法。範例: + +```go +// internal/middleware/authjwt_middleware.go ← 檔名由 goctl stringx 決定 +package middleware + +type AuthJWTMiddleware struct { + tokens domauth.TokenUseCase +} + +func NewAuthJWTMiddleware(tokens domauth.TokenUseCase) *AuthJWTMiddleware { + return &AuthJWTMiddleware{tokens: tokens} +} + +func (m *AuthJWTMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // ...檢查 / 注入 actor / 呼叫 next(w, r) + } +} +``` + +**檔名規則**:goctl 會依 `.api` 的 `middleware:` 名字產生 stub 檔,命名 = `stringx.From().ToLower() + "_middleware.go"`,例如: +- `AuthJWT` → `authjwt_middleware.go` +- `CasbinRBAC` → `casbinrbac_middleware.go` + +新 middleware **必須**直接用這個檔名寫實作,goctl 下次 gen-api 才會跳過(看到「已存在」)。 + +### ServiceContext 注入 + +在 `internal/svc/service_context.go` 暴露 `rest.Middleware` 欄位並在 `NewServiceContext` 結尾 wire 起來: + +```go +type ServiceContext struct { + ... + AuthJWT rest.Middleware + CasbinRBAC rest.Middleware +} + +func NewServiceContext(c config.Config) *ServiceContext { + ... + // 結尾,所有 use case 都建好之後 + sc.AuthJWT = middleware.NewAuthJWTMiddleware(sc.AuthToken).Handle + sc.CasbinRBAC = middleware.NewCasbinRBACMiddleware(sc.PermissionRBAC, middleware.CasbinRBACOptions{}).Handle + return sc +} +``` + +`.Handle` 是 method value(`func(next http.HandlerFunc) http.HandlerFunc`),剛好符合 `rest.Middleware` 簽名。 + +### Actor context 共享 + +JWT middleware 解析完 token 後用 `internal/library/actor.WithActor(ctx, tenantID, uid)` 注入 actor。 +**所有層**(logic / usecase / 其他 middleware)讀 actor 都用 `actor.ActorFromContext(ctx)` —— 不要在各 logic 套件各自定義 `actorKey struct{}`,否則 context value 進不來。 + +`internal/logic/member/actor.go` 與 `internal/logic/permission/actor.go` 是 `library/actor` 的 thin alias,方便既有程式用 `member.Actor` / `member.ActorFromContext`,但底層 key 是同一個。 + ## 與 runtime 對齊 Handler 使用 `response.Write` 輸出: diff --git a/generate/api/auth.api b/generate/api/auth.api index 6db1f4f..7a8db54 100644 --- a/generate/api/auth.api +++ b/generate/api/auth.api @@ -129,8 +129,8 @@ type ( @server( group: auth prefix: /api/v1/auth - tags: "Auth - 認證" - summary: "註冊 / 登入 / Token / 登出" + tags: "Auth - 認證(公開)" + summary: "註冊 / 登入 / Token 交換(不需 Bearer)" ) service gateway { @doc "Email 註冊(建立 ZITADEL + member,寄 registration OTP)" @@ -434,7 +434,16 @@ service gateway { */ @handler loginSocialCallback get /login/social/callback (LoginSocialCallbackReq) returns (AuthTokenData) +} +@server( + group: auth + prefix: /api/v1/auth + middleware: AuthJWT + tags: "Auth - 認證(需 Bearer)" + summary: "登出(需 access token)" +) +service gateway { @doc "登出(撤銷 access JWT 及配對 refresh JWT)" /* @respdoc-200 (LogoutOKStatus) // 成功(code=102000) diff --git a/generate/api/member.api b/generate/api/member.api index 9c49093..ddb9a07 100644 --- a/generate/api/member.api +++ b/generate/api/member.api @@ -115,10 +115,11 @@ type ( ) @server( - group: member - prefix: /api/v1/members - tags: "Member - 會員" - summary: "Profile / 業務 Email 與 Phone 驗證 / TOTP MFA" + group: member + prefix: /api/v1/members + middleware: AuthJWT + tags: "Member - 會員" + summary: "Profile / 業務 Email 與 Phone 驗證 / TOTP MFA(需 Bearer)" ) service gateway { @doc "取得當前會員 profile(Bearer JWT;本機 dev 可 fallback X-Tenant-ID + X-UID)" diff --git a/generate/api/permission.api b/generate/api/permission.api index e867775..17cfc50 100644 --- a/generate/api/permission.api +++ b/generate/api/permission.api @@ -256,10 +256,11 @@ type ( ) @server( - group: permission - prefix: /api/v1/permissions - tags: "Permission - 權限" - summary: "Catalog / 角色 / 使用者角色 / 外部映射 / Policy" + group: permission + prefix: /api/v1/permissions + middleware: AuthJWT + tags: "Permission - 權限(讀取)" + summary: "Catalog / Me(需 Bearer,不過 RBAC 檢查)" ) service gateway { @doc "取得全局 Permission Catalog(樹狀或扁平;可篩 status/type)" @@ -295,7 +296,16 @@ service gateway { */ @handler getMePermissions get /me (MePermissionsQuery) returns (MePermissionsData) +} +@server( + group: permission + prefix: /api/v1/permissions + middleware: AuthJWT,CasbinRBAC + tags: "Permission - 權限(管理)" + summary: "角色 / 使用者角色 / 外部映射 / Policy(需 Bearer + RBAC)" +) +service gateway { @doc "列出租戶內所有角色(含 system role)" /* @respdoc-200 (RoleListOKStatus) // 成功 diff --git a/internal/handler/routes.go b/internal/handler/routes.go index 82cdbf2..06d3d25 100644 --- a/internal/handler/routes.go +++ b/internal/handler/routes.go @@ -37,12 +37,6 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { Path: "/login/social/start", Handler: auth.LoginSocialStartHandler(serverCtx), }, - { - // 登出(撤銷 access JWT 及配對 refresh JWT) - Method: http.MethodPost, - Path: "/logout", - Handler: auth.LogoutHandler(serverCtx), - }, { // Email 註冊(建立 ZITADEL + member,寄 registration OTP) Method: http.MethodPost, @@ -90,80 +84,98 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { ) server.AddRoutes( - []rest.Route{ - { - // 取得當前會員 profile(Bearer JWT;本機 dev 可 fallback X-Tenant-ID + X-UID) - Method: http.MethodGet, - Path: "/me", - Handler: member.GetMemberMeHandler(serverCtx), - }, - { - // 更新當前會員 profile - Method: http.MethodPatch, - Path: "/me", - Handler: member.UpdateMemberMeHandler(serverCtx), - }, - { - // TOTP 狀態 - Method: http.MethodGet, - Path: "/me/totp", - Handler: member.GetTOTPStatusHandler(serverCtx), - }, - { - // 解除 TOTP 綁定 - Method: http.MethodDelete, - Path: "/me/totp", - Handler: member.DisableTOTPHandler(serverCtx), - }, - { - // 重產 TOTP 備援碼 - Method: http.MethodPost, - Path: "/me/totp/backup-codes", - Handler: member.RegenerateTOTPBackupCodesHandler(serverCtx), - }, - { - // 確認 TOTP 綁定 - Method: http.MethodPost, - Path: "/me/totp/enroll-confirm", - Handler: member.ConfirmTOTPEnrollHandler(serverCtx), - }, - { - // 開始 TOTP 綁定 - Method: http.MethodPost, - Path: "/me/totp/enroll-start", - Handler: member.StartTOTPEnrollHandler(serverCtx), - }, - { - // 驗證 TOTP(step-up 測試) - Method: http.MethodPost, - Path: "/me/totp/verify", - Handler: member.VerifyTOTPHandler(serverCtx), - }, - { - // 確認業務 email 驗證 - Method: http.MethodPost, - Path: "/me/verifications/email/confirm", - Handler: member.ConfirmEmailVerificationHandler(serverCtx), - }, - { - // 開始業務 email 驗證 - Method: http.MethodPost, - Path: "/me/verifications/email/start", - Handler: member.StartEmailVerificationHandler(serverCtx), - }, - { - // 確認業務 phone 驗證 - Method: http.MethodPost, - Path: "/me/verifications/phone/confirm", - Handler: member.ConfirmPhoneVerificationHandler(serverCtx), - }, - { - // 開始業務 phone 驗證 - Method: http.MethodPost, - Path: "/me/verifications/phone/start", - Handler: member.StartPhoneVerificationHandler(serverCtx), - }, - }, + rest.WithMiddlewares( + []rest.Middleware{serverCtx.AuthJWT}, + []rest.Route{ + { + // 登出(撤銷 access JWT 及配對 refresh JWT) + Method: http.MethodPost, + Path: "/logout", + Handler: auth.LogoutHandler(serverCtx), + }, + }..., + ), + rest.WithPrefix("/api/v1/auth"), + ) + + server.AddRoutes( + rest.WithMiddlewares( + []rest.Middleware{serverCtx.AuthJWT}, + []rest.Route{ + { + // 取得當前會員 profile(Bearer JWT;本機 dev 可 fallback X-Tenant-ID + X-UID) + Method: http.MethodGet, + Path: "/me", + Handler: member.GetMemberMeHandler(serverCtx), + }, + { + // 更新當前會員 profile + Method: http.MethodPatch, + Path: "/me", + Handler: member.UpdateMemberMeHandler(serverCtx), + }, + { + // TOTP 狀態 + Method: http.MethodGet, + Path: "/me/totp", + Handler: member.GetTOTPStatusHandler(serverCtx), + }, + { + // 解除 TOTP 綁定 + Method: http.MethodDelete, + Path: "/me/totp", + Handler: member.DisableTOTPHandler(serverCtx), + }, + { + // 重產 TOTP 備援碼 + Method: http.MethodPost, + Path: "/me/totp/backup-codes", + Handler: member.RegenerateTOTPBackupCodesHandler(serverCtx), + }, + { + // 確認 TOTP 綁定 + Method: http.MethodPost, + Path: "/me/totp/enroll-confirm", + Handler: member.ConfirmTOTPEnrollHandler(serverCtx), + }, + { + // 開始 TOTP 綁定 + Method: http.MethodPost, + Path: "/me/totp/enroll-start", + Handler: member.StartTOTPEnrollHandler(serverCtx), + }, + { + // 驗證 TOTP(step-up 測試) + Method: http.MethodPost, + Path: "/me/totp/verify", + Handler: member.VerifyTOTPHandler(serverCtx), + }, + { + // 確認業務 email 驗證 + Method: http.MethodPost, + Path: "/me/verifications/email/confirm", + Handler: member.ConfirmEmailVerificationHandler(serverCtx), + }, + { + // 開始業務 email 驗證 + Method: http.MethodPost, + Path: "/me/verifications/email/start", + Handler: member.StartEmailVerificationHandler(serverCtx), + }, + { + // 確認業務 phone 驗證 + Method: http.MethodPost, + Path: "/me/verifications/phone/confirm", + Handler: member.ConfirmPhoneVerificationHandler(serverCtx), + }, + { + // 開始業務 phone 驗證 + Method: http.MethodPost, + Path: "/me/verifications/phone/start", + Handler: member.StartPhoneVerificationHandler(serverCtx), + }, + }..., + ), rest.WithPrefix("/api/v1/members"), ) @@ -181,98 +193,110 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { ) server.AddRoutes( - []rest.Route{ - { - // 取得全局 Permission Catalog(樹狀或扁平;可篩 status/type) - Method: http.MethodGet, - Path: "/catalog", - Handler: permission.GetPermissionCatalogHandler(serverCtx), - }, - { - // 取得當前使用者的 role / permission map(前端渲染選單) - Method: http.MethodGet, - Path: "/me", - Handler: permission.GetMePermissionsHandler(serverCtx), - }, - { - // 強制重載 Casbin policy(單租戶或所有租戶;同步 + Pub/Sub broadcast) - Method: http.MethodPost, - Path: "/policy/reload", - Handler: permission.ReloadPolicyHandler(serverCtx), - }, - { - // 列出外部來源 → 內部 role 的映射(zitadel / ldap / scim) - Method: http.MethodGet, - Path: "/role-mappings", - Handler: permission.ListRoleMappingsHandler(serverCtx), - }, - { - // Upsert 外部 IdP 群組到內部 role 的映射 - Method: http.MethodPut, - Path: "/role-mappings", - Handler: permission.UpsertRoleMappingHandler(serverCtx), - }, - { - // 刪除外部 → 內部 role 映射 - Method: http.MethodDelete, - Path: "/role-mappings", - Handler: permission.DeleteRoleMappingHandler(serverCtx), - }, - { - // 列出租戶內所有角色(含 system role) - Method: http.MethodGet, - Path: "/roles", - Handler: permission.ListRolesHandler(serverCtx), - }, - { - // 建立租戶自訂角色(key 不可改、不可使用 system./platform_ 開頭) - Method: http.MethodPost, - Path: "/roles", - Handler: permission.CreateRoleHandler(serverCtx), - }, - { - // 更新角色(display_name / status;is_system 角色不可改 status) - Method: http.MethodPatch, - Path: "/roles/:id", - Handler: permission.UpdateRoleHandler(serverCtx), - }, - { - // 刪除角色(is_system 不可刪;存在 user 指派時拒絕) - Method: http.MethodDelete, - Path: "/roles/:id", - Handler: permission.DeleteRoleHandler(serverCtx), - }, - { - // 讀取角色目前勾選的 permission 集合 - Method: http.MethodGet, - Path: "/roles/:id/permissions", - Handler: permission.GetRolePermissionsHandler(serverCtx), - }, - { - // 全量取代角色的 permission 勾選(自動補齊父權限;觸發 LoadPolicy + Pub/Sub reload) - Method: http.MethodPut, - Path: "/roles/:id/permissions", - Handler: permission.ReplaceRolePermissionsHandler(serverCtx), - }, - { - // 查詢使用者目前指派的角色(含 RoleKey / DisplayName) - Method: http.MethodGet, - Path: "/users/:uid/roles", - Handler: permission.ListUserRolesHandler(serverCtx), - }, - { - // 指派角色給使用者(預設 source=manual;source 來源由 SyncFromX 自動標) - Method: http.MethodPost, - Path: "/users/:uid/roles", - Handler: permission.AssignUserRoleHandler(serverCtx), - }, - { - // 撤銷使用者的單一角色 - Method: http.MethodDelete, - Path: "/users/:uid/roles/:role_id", - Handler: permission.RevokeUserRoleHandler(serverCtx), - }, - }, + rest.WithMiddlewares( + []rest.Middleware{serverCtx.AuthJWT}, + []rest.Route{ + { + // 取得全局 Permission Catalog(樹狀或扁平;可篩 status/type) + Method: http.MethodGet, + Path: "/catalog", + Handler: permission.GetPermissionCatalogHandler(serverCtx), + }, + { + // 取得當前使用者的 role / permission map(前端渲染選單) + Method: http.MethodGet, + Path: "/me", + Handler: permission.GetMePermissionsHandler(serverCtx), + }, + }..., + ), + rest.WithPrefix("/api/v1/permissions"), + ) + + server.AddRoutes( + rest.WithMiddlewares( + []rest.Middleware{serverCtx.AuthJWT, serverCtx.CasbinRBAC}, + []rest.Route{ + { + // 強制重載 Casbin policy(單租戶或所有租戶;同步 + Pub/Sub broadcast) + Method: http.MethodPost, + Path: "/policy/reload", + Handler: permission.ReloadPolicyHandler(serverCtx), + }, + { + // 列出外部來源 → 內部 role 的映射(zitadel / ldap / scim) + Method: http.MethodGet, + Path: "/role-mappings", + Handler: permission.ListRoleMappingsHandler(serverCtx), + }, + { + // Upsert 外部 IdP 群組到內部 role 的映射 + Method: http.MethodPut, + Path: "/role-mappings", + Handler: permission.UpsertRoleMappingHandler(serverCtx), + }, + { + // 刪除外部 → 內部 role 映射 + Method: http.MethodDelete, + Path: "/role-mappings", + Handler: permission.DeleteRoleMappingHandler(serverCtx), + }, + { + // 列出租戶內所有角色(含 system role) + Method: http.MethodGet, + Path: "/roles", + Handler: permission.ListRolesHandler(serverCtx), + }, + { + // 建立租戶自訂角色(key 不可改、不可使用 system./platform_ 開頭) + Method: http.MethodPost, + Path: "/roles", + Handler: permission.CreateRoleHandler(serverCtx), + }, + { + // 更新角色(display_name / status;is_system 角色不可改 status) + Method: http.MethodPatch, + Path: "/roles/:id", + Handler: permission.UpdateRoleHandler(serverCtx), + }, + { + // 刪除角色(is_system 不可刪;存在 user 指派時拒絕) + Method: http.MethodDelete, + Path: "/roles/:id", + Handler: permission.DeleteRoleHandler(serverCtx), + }, + { + // 讀取角色目前勾選的 permission 集合 + Method: http.MethodGet, + Path: "/roles/:id/permissions", + Handler: permission.GetRolePermissionsHandler(serverCtx), + }, + { + // 全量取代角色的 permission 勾選(自動補齊父權限;觸發 LoadPolicy + Pub/Sub reload) + Method: http.MethodPut, + Path: "/roles/:id/permissions", + Handler: permission.ReplaceRolePermissionsHandler(serverCtx), + }, + { + // 查詢使用者目前指派的角色(含 RoleKey / DisplayName) + Method: http.MethodGet, + Path: "/users/:uid/roles", + Handler: permission.ListUserRolesHandler(serverCtx), + }, + { + // 指派角色給使用者(預設 source=manual;source 來源由 SyncFromX 自動標) + Method: http.MethodPost, + Path: "/users/:uid/roles", + Handler: permission.AssignUserRoleHandler(serverCtx), + }, + { + // 撤銷使用者的單一角色 + Method: http.MethodDelete, + Path: "/users/:uid/roles/:role_id", + Handler: permission.RevokeUserRoleHandler(serverCtx), + }, + }..., + ), rest.WithPrefix("/api/v1/permissions"), ) } diff --git a/internal/library/actor/actor.go b/internal/library/actor/actor.go new file mode 100644 index 0000000..9f21f24 --- /dev/null +++ b/internal/library/actor/actor.go @@ -0,0 +1,45 @@ +// Package actor stores the calling user (tenant_id + uid) on a request +// context so middleware can inject it once and any downstream layer +// (handler / logic / usecase / repository) can read it without +// re-parsing the JWT. +// +// The package lives under internal/library because it is depended on +// by BOTH internal/middleware AND internal/logic/*. Defining the +// context key here is the only way to keep a single key value across +// packages (Go context.Value keys are typed by package, so each +// package would otherwise get its own isolated slot). +package actor + +import ( + "context" + "fmt" +) + +type contextKey struct{} + +// Actor identifies the calling tenant member, injected by the AuthJWT +// middleware after a successful Bearer token parse. +type Actor struct { + TenantID string + UID string +} + +// WithActor stores tenant/uid on ctx. Returns the original ctx +// unchanged when either field is empty. +func WithActor(ctx context.Context, tenantID, uid string) context.Context { + if tenantID == "" || uid == "" { + return ctx + } + return context.WithValue(ctx, contextKey{}, Actor{TenantID: tenantID, UID: uid}) +} + +// ActorFromContext returns the actor injected by AuthJWT. The error is +// a plain error (no biz code) so callers can wrap it with their own +// scope-specific Builder when needed. +func ActorFromContext(ctx context.Context) (Actor, error) { + v, ok := ctx.Value(contextKey{}).(Actor) + if !ok || v.TenantID == "" || v.UID == "" { + return Actor{}, fmt.Errorf("missing bearer token or X-Tenant-ID/X-UID headers") + } + return v, nil +} diff --git a/internal/logic/member/actor.go b/internal/logic/member/actor.go index 47e9018..e0a2ab5 100644 --- a/internal/logic/member/actor.go +++ b/internal/logic/member/actor.go @@ -2,27 +2,22 @@ package member import ( "context" - "fmt" + + "gateway/internal/library/actor" ) -type actorKey struct{} - -// Actor identifies the calling member (JWT middleware or dev headers). -type Actor struct { - TenantID string - UID string -} +// Actor aliases library/actor.Actor so existing logic code keeps +// referring to `member.Actor` without an import change. +type Actor = actor.Actor // WithActor stores tenant/uid on the context for member logic handlers. +// Delegates to library/actor so AuthJWT middleware and downstream +// readers share the same context key. func WithActor(ctx context.Context, tenantID, uid string) context.Context { - return context.WithValue(ctx, actorKey{}, Actor{TenantID: tenantID, UID: uid}) + return actor.WithActor(ctx, tenantID, uid) } -// ActorFromContext reads the member actor injected by JWT middleware or dev headers. +// ActorFromContext reads the actor injected by AuthJWT middleware. func ActorFromContext(ctx context.Context) (Actor, error) { - v, ok := ctx.Value(actorKey{}).(Actor) - if !ok || v.TenantID == "" || v.UID == "" { - return Actor{}, fmt.Errorf("missing bearer token or X-Tenant-ID/X-UID headers") - } - return v, nil + return actor.ActorFromContext(ctx) } diff --git a/internal/logic/permission/actor.go b/internal/logic/permission/actor.go index f38fac3..abc0c24 100644 --- a/internal/logic/permission/actor.go +++ b/internal/logic/permission/actor.go @@ -2,31 +2,21 @@ package permission import ( "context" - "fmt" + + "gateway/internal/library/actor" ) -type actorKey struct{} - -// Actor identifies the calling tenant member (Bearer JWT or dev headers). -// Permission logic always needs (tenant_id, uid) so the auth → permission -// boundary is explicit; pulling from headers is dev-only. -type Actor struct { - TenantID string - UID string -} +// Actor aliases library/actor.Actor so existing permission logic keeps +// referring to `permission.Actor` without an import change. +type Actor = actor.Actor // WithActor stores tenant/uid on the context for permission logic -// handlers. +// handlers. Delegates to library/actor. func WithActor(ctx context.Context, tenantID, uid string) context.Context { - return context.WithValue(ctx, actorKey{}, Actor{TenantID: tenantID, UID: uid}) + return actor.WithActor(ctx, tenantID, uid) } -// ActorFromContext reads the actor injected by JWT middleware or dev -// headers. +// ActorFromContext reads the actor injected by AuthJWT middleware. func ActorFromContext(ctx context.Context) (Actor, error) { - v, ok := ctx.Value(actorKey{}).(Actor) - if !ok || v.TenantID == "" || v.UID == "" { - return Actor{}, fmt.Errorf("missing bearer token or X-Tenant-ID/X-UID headers") - } - return v, nil + return actor.ActorFromContext(ctx) } diff --git a/internal/middleware/auth_jwt.go b/internal/middleware/auth_jwt.go deleted file mode 100644 index 192179b..0000000 --- a/internal/middleware/auth_jwt.go +++ /dev/null @@ -1,47 +0,0 @@ -package middleware - -import ( - "net/http" - "strings" - - errs "gateway/internal/library/errors" - "gateway/internal/library/errors/code" - logicmember "gateway/internal/logic/member" - domauth "gateway/internal/model/auth/domain/usecase" - "gateway/internal/response" - - "github.com/zeromicro/go-zero/rest" -) - -// CloudEPJWT parses Bearer access tokens and injects member actor into request context. -// When token is absent or invalid, the request proceeds unchanged (dev headers may still apply on member routes). -func CloudEPJWT(tokens domauth.TokenUseCase) rest.Middleware { - return func(next http.HandlerFunc) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - raw := bearerToken(r.Header.Get("Authorization")) - if raw != "" && tokens != nil { - claims, err := tokens.ParseAccessToken(ctx, raw) - if err == nil { - ctx = logicmember.WithActor(ctx, claims.TenantID, claims.UID) - r = r.WithContext(ctx) - next(w, r) - return - } - if e := errs.FromError(err); e != nil && e.Category() == code.AuthUnauthorized { - response.Write(r.Context(), w, nil, err) - return - } - } - next(w, r) - } - } -} - -func bearerToken(header string) string { - const prefix = "Bearer " - if !strings.HasPrefix(header, prefix) { - return "" - } - return strings.TrimSpace(strings.TrimPrefix(header, prefix)) -} diff --git a/internal/middleware/authjwt_middleware.go b/internal/middleware/authjwt_middleware.go new file mode 100644 index 0000000..6c24406 --- /dev/null +++ b/internal/middleware/authjwt_middleware.go @@ -0,0 +1,77 @@ +package middleware + +import ( + "net/http" + "strings" + + "gateway/internal/library/actor" + errs "gateway/internal/library/errors" + "gateway/internal/library/errors/code" + domauth "gateway/internal/model/auth/domain/usecase" + "gateway/internal/response" +) + +// AuthJWTMiddleware enforces Bearer access tokens on protected routes. +// +// Mounted via @server(middleware: AuthJWT) in the .api file. Missing or +// invalid tokens return 28501000 (Auth scope, unauthorized) — public +// routes (register / login / token refresh / health) must NOT mount +// this middleware. +// +// On success the parsed (tenant, uid) is injected via library/actor +// so downstream logic can read it through actor.ActorFromContext (or +// the package-local member.ActorFromContext / permission.ActorFromContext +// aliases). +// +// File name follows goctl's stringx convention (`authjwt_middleware.go`) +// so `make gen-api` sees it as already-generated and never overwrites +// the implementation. +type AuthJWTMiddleware struct { + tokens domauth.TokenUseCase +} + +// NewAuthJWTMiddleware wires the middleware with the auth module's +// TokenUseCase (set up in ServiceContext.NewServiceContext). +func NewAuthJWTMiddleware(tokens domauth.TokenUseCase) *AuthJWTMiddleware { + return &AuthJWTMiddleware{tokens: tokens} +} + +// Handle implements the go-zero rest.Middleware signature. +func (m *AuthJWTMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc { + bld := errs.For(code.Auth) + return func(w http.ResponseWriter, r *http.Request) { + if m.tokens == nil { + response.Write(r.Context(), w, nil, + bld.SysNotImplemented("auth middleware: token usecase not configured")) + return + } + raw := bearerToken(r.Header.Get("Authorization")) + if raw == "" { + response.Write(r.Context(), w, nil, + bld.AuthUnauthorized("missing bearer token")) + return + } + claims, err := m.tokens.ParseAccessToken(r.Context(), raw) + if err != nil { + // surface already-typed Auth errors as-is so the biz code + // (e.g. expired vs invalid) is preserved. + if e := errs.FromError(err); e != nil && e.Category() == code.AuthUnauthorized { + response.Write(r.Context(), w, nil, err) + return + } + response.Write(r.Context(), w, nil, + bld.AuthUnauthorized("invalid bearer token").WithCause(err)) + return + } + ctx := actor.WithActor(r.Context(), claims.TenantID, claims.UID) + next(w, r.WithContext(ctx)) + } +} + +func bearerToken(header string) string { + const prefix = "Bearer " + if !strings.HasPrefix(header, prefix) { + return "" + } + return strings.TrimSpace(strings.TrimPrefix(header, prefix)) +} diff --git a/internal/middleware/casbin_rbac.go b/internal/middleware/casbin_rbac.go deleted file mode 100644 index cadd798..0000000 --- a/internal/middleware/casbin_rbac.go +++ /dev/null @@ -1,92 +0,0 @@ -package middleware - -import ( - "net/http" - - errs "gateway/internal/library/errors" - "gateway/internal/library/errors/code" - logicmember "gateway/internal/logic/member" - domperm "gateway/internal/model/permission/domain/usecase" - "gateway/internal/response" - - "github.com/zeromicro/go-zero/core/logx" - "github.com/zeromicro/go-zero/rest" -) - -// CasbinRBACOptions tunes the enforcement middleware. -type CasbinRBACOptions struct { - // PlatformAdminRoleKey short-circuits enforcement when the actor's - // auth context flags them as platform admin (handled upstream); the - // value is the role key seeded for that role (e.g. - // "platform_super_admin"). Empty disables the bypass. - PlatformAdminRoleKey string - - // AllowMissingActor lets unauthenticated requests through without - // enforcement. Set to true on routes that do their own auth (e.g. - // public catalog reads in dev mode). - AllowMissingActor bool - - // SkipPaths is an exact-match allowlist (e.g. /api/v1/health). Useful - // for opting out specific routes when the middleware is mounted - // globally. - SkipPaths map[string]struct{} -} - -// CasbinRBAC returns a go-zero middleware that calls -// rbac.Check(tenant, uid, path, method) and rejects with HTTP 403 when -// the result is deny. -// -// The middleware is intentionally NOT wired into routes.go yet — wiring -// happens once the platform admin role + audit log pipeline are in -// place (design §6.7, §8.2). To opt-in, append it to a route group's -// middleware chain in routes.go. -func CasbinRBAC(rbac domperm.RBACUseCase, opts CasbinRBACOptions) rest.Middleware { - skip := opts.SkipPaths - if skip == nil { - skip = map[string]struct{}{} - } - bld := errs.For(code.Permission) - - return func(next http.HandlerFunc) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if rbac == nil { - next(w, r) - return - } - if _, ok := skip[r.URL.Path]; ok { - next(w, r) - return - } - actor, err := logicmember.ActorFromContext(r.Context()) - if err != nil { - if opts.AllowMissingActor { - next(w, r) - return - } - response.Write(r.Context(), w, nil, - bld.AuthUnauthorized("missing actor for rbac check").WithCause(err)) - return - } - result, err := rbac.Check(r.Context(), &domperm.CheckRequest{ - TenantID: actor.TenantID, - UID: actor.UID, - Path: r.URL.Path, - Method: r.Method, - }) - if err != nil { - logx.WithContext(r.Context()).Errorf( - "casbin: enforce error tenant=%s uid=%s path=%s method=%s: %v", - actor.TenantID, actor.UID, r.URL.Path, r.Method, err) - response.Write(r.Context(), w, nil, - bld.SysInternal("casbin enforce failed").WithCause(err)) - return - } - if !result.Allow { - response.Write(r.Context(), w, nil, - bld.AuthForbidden("rbac denied").WithCause(nil)) - return - } - next(w, r) - } - } -} diff --git a/internal/middleware/casbinrbac_middleware.go b/internal/middleware/casbinrbac_middleware.go new file mode 100644 index 0000000..64445a5 --- /dev/null +++ b/internal/middleware/casbinrbac_middleware.go @@ -0,0 +1,96 @@ +package middleware + +import ( + "net/http" + + "gateway/internal/library/actor" + errs "gateway/internal/library/errors" + "gateway/internal/library/errors/code" + domperm "gateway/internal/model/permission/domain/usecase" + "gateway/internal/response" + + "github.com/zeromicro/go-zero/core/logx" +) + +// CasbinRBACOptions tunes the enforcement middleware. +type CasbinRBACOptions struct { + // PlatformAdminRoleKey short-circuits enforcement when the actor's + // auth context flags them as platform admin (handled upstream); the + // value is the role key seeded for that role (e.g. + // "platform_super_admin"). Empty disables the bypass. + PlatformAdminRoleKey string + + // SkipPaths is an exact-match allowlist (e.g. /api/v1/health). + // Useful for opting out specific routes when the middleware is + // mounted on a wider group. + SkipPaths map[string]struct{} +} + +// CasbinRBACMiddleware enforces (tenant, uid, path, method) against +// the Casbin policy loaded by the permission module. It MUST be +// preceded by AuthJWTMiddleware in the chain so the actor is already +// present on the request context (via library/actor). +// +// Mounted via @server(middleware: AuthJWT,CasbinRBAC). Missing actor +// is treated as a hard 401 (the chain order guarantees AuthJWT runs +// first); a successful actor with no matching policy returns 403 +// (28505000 in Permission scope). +// +// File name follows goctl's stringx convention (`casbinrbac_middleware.go`) +// so `make gen-api` sees it as already-generated and never overwrites +// the implementation. +type CasbinRBACMiddleware struct { + rbac domperm.RBACUseCase + opts CasbinRBACOptions +} + +// NewCasbinRBACMiddleware wires the middleware with the permission +// module's RBACUseCase. A nil rbac transparently allows requests +// through (useful while the module is being rolled out). +func NewCasbinRBACMiddleware(rbac domperm.RBACUseCase, opts CasbinRBACOptions) *CasbinRBACMiddleware { + if opts.SkipPaths == nil { + opts.SkipPaths = map[string]struct{}{} + } + return &CasbinRBACMiddleware{rbac: rbac, opts: opts} +} + +// Handle implements the go-zero rest.Middleware signature. +func (m *CasbinRBACMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc { + bld := errs.For(code.Permission) + return func(w http.ResponseWriter, r *http.Request) { + if m.rbac == nil { + next(w, r) + return + } + if _, ok := m.opts.SkipPaths[r.URL.Path]; ok { + next(w, r) + return + } + caller, err := actor.ActorFromContext(r.Context()) + if err != nil { + response.Write(r.Context(), w, nil, + bld.AuthUnauthorized("missing actor for rbac check").WithCause(err)) + return + } + result, err := m.rbac.Check(r.Context(), &domperm.CheckRequest{ + TenantID: caller.TenantID, + UID: caller.UID, + Path: r.URL.Path, + Method: r.Method, + }) + if err != nil { + logx.WithContext(r.Context()).Errorf( + "casbin: enforce error tenant=%s uid=%s path=%s method=%s: %v", + caller.TenantID, caller.UID, r.URL.Path, r.Method, err) + response.Write(r.Context(), w, nil, + bld.SysInternal("casbin enforce failed").WithCause(err)) + return + } + if !result.Allow { + response.Write(r.Context(), w, nil, + bld.AuthForbidden("rbac denied")) + return + } + next(w, r) + } +} diff --git a/internal/svc/service_context.go b/internal/svc/service_context.go index 11f1116..f6b07e4 100644 --- a/internal/svc/service_context.go +++ b/internal/svc/service_context.go @@ -11,6 +11,7 @@ import ( redislib "gateway/internal/library/redis" "gateway/internal/library/validate" "gateway/internal/library/zitadel" + "gateway/internal/middleware" authdomrepo "gateway/internal/model/auth/domain/repository" domauth "gateway/internal/model/auth/domain/usecase" authrepo "gateway/internal/model/auth/repository" @@ -24,6 +25,8 @@ import ( domperm "gateway/internal/model/permission/domain/usecase" permusecase "gateway/internal/model/permission/usecase" "gateway/internal/worker/notification_retry" + + "github.com/zeromicro/go-zero/rest" ) type ServiceContext struct { @@ -58,6 +61,12 @@ type ServiceContext struct { PermissionRBAC domperm.RBACUseCase PermissionRoleRepo dompermrepo.RoleRepository permissionModule *permusecase.Module + + // Middlewares mounted per route group via .api `middleware:` directive. + // AuthJWT enforces Bearer token (sets actor on ctx). + // CasbinRBAC enforces RBAC; must be chained AFTER AuthJWT. + AuthJWT rest.Middleware + CasbinRBAC rest.Middleware } func NewServiceContext(c config.Config) *ServiceContext { @@ -158,6 +167,13 @@ func NewServiceContext(c config.Config) *ServiceContext { sc.PermissionRoleRepo = permMod.Roles sc.permissionModule = permMod } + + // Wire middleware last so dependencies (AuthToken / PermissionRBAC) + // are populated. Each middleware tolerates nil deps (auth → 501, + // rbac → passthrough) so the gateway boots even with partial config. + sc.AuthJWT = middleware.NewAuthJWTMiddleware(sc.AuthToken).Handle + sc.CasbinRBAC = middleware.NewCasbinRBACMiddleware(sc.PermissionRBAC, middleware.CasbinRBACOptions{}).Handle + return sc }