refactor(middleware): wire AuthJWT + CasbinRBAC via .api middleware directive
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 <cursoragent@cursor.com>
This commit is contained in:
parent
4590f1c951
commit
bdeb7e8263
23
AGENTS.md
23
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` 加 `<Name> rest.Middleware` 欄位,於 `NewServiceContext` 結尾 wire `sc.<Name> = middleware.New<Name>Middleware(...).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/<module>/config/config.go`,再合入 `internal/config/config.go` 的 `Config` struct
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(<Name>).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` 輸出:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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) // 成功
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue