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 註冊在 `internal/library/errors/code/types.go`(Facade=10, Auth=28, Member=29, Notification=30, Permission=31)
|
||||||
- 新增 scope 時:同步更新 `gateway.api` 的 `bizCodeEnumDescription`
|
- 新增 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 / 設定
|
### 4. Redis / Mongo / 設定
|
||||||
|
|
||||||
- 每個模組的設定型別放 `internal/model/<module>/config/config.go`,再合入 `internal/config/config.go` 的 `Config` struct
|
- 每個模組的設定型別放 `internal/model/<module>/config/config.go`,再合入 `internal/config/config.go` 的 `Config` struct
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ import (
|
||||||
"gateway/internal/config"
|
"gateway/internal/config"
|
||||||
"gateway/internal/handler"
|
"gateway/internal/handler"
|
||||||
"gateway/internal/library/errors/code"
|
"gateway/internal/library/errors/code"
|
||||||
"gateway/internal/middleware"
|
|
||||||
"gateway/internal/response"
|
"gateway/internal/response"
|
||||||
"gateway/internal/svc"
|
"gateway/internal/svc"
|
||||||
|
|
||||||
|
|
@ -36,9 +35,8 @@ func main() {
|
||||||
defer server.Stop()
|
defer server.Stop()
|
||||||
|
|
||||||
sc := svc.NewServiceContext(c)
|
sc := svc.NewServiceContext(c)
|
||||||
if sc.AuthToken != nil {
|
// Middlewares are now mounted per route group via .api `middleware:`
|
||||||
server.Use(middleware.CloudEPJWT(sc.AuthToken))
|
// directives (AuthJWT / CasbinRBAC). See generate/api/README.md.
|
||||||
}
|
|
||||||
handler.RegisterHandlers(server, sc)
|
handler.RegisterHandlers(server, sc)
|
||||||
|
|
||||||
workerCtx, stopWorkers := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
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 都正確顯示。
|
驗證:把 `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 對齊
|
## 與 runtime 對齊
|
||||||
|
|
||||||
Handler 使用 `response.Write` 輸出:
|
Handler 使用 `response.Write` 輸出:
|
||||||
|
|
|
||||||
|
|
@ -129,8 +129,8 @@ type (
|
||||||
@server(
|
@server(
|
||||||
group: auth
|
group: auth
|
||||||
prefix: /api/v1/auth
|
prefix: /api/v1/auth
|
||||||
tags: "Auth - 認證"
|
tags: "Auth - 認證(公開)"
|
||||||
summary: "註冊 / 登入 / Token / 登出"
|
summary: "註冊 / 登入 / Token 交換(不需 Bearer)"
|
||||||
)
|
)
|
||||||
service gateway {
|
service gateway {
|
||||||
@doc "Email 註冊(建立 ZITADEL + member,寄 registration OTP)"
|
@doc "Email 註冊(建立 ZITADEL + member,寄 registration OTP)"
|
||||||
|
|
@ -434,7 +434,16 @@ service gateway {
|
||||||
*/
|
*/
|
||||||
@handler loginSocialCallback
|
@handler loginSocialCallback
|
||||||
get /login/social/callback (LoginSocialCallbackReq) returns (AuthTokenData)
|
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)"
|
@doc "登出(撤銷 access JWT 及配對 refresh JWT)"
|
||||||
/*
|
/*
|
||||||
@respdoc-200 (LogoutOKStatus) // 成功(code=102000)
|
@respdoc-200 (LogoutOKStatus) // 成功(code=102000)
|
||||||
|
|
|
||||||
|
|
@ -115,10 +115,11 @@ type (
|
||||||
)
|
)
|
||||||
|
|
||||||
@server(
|
@server(
|
||||||
group: member
|
group: member
|
||||||
prefix: /api/v1/members
|
prefix: /api/v1/members
|
||||||
tags: "Member - 會員"
|
middleware: AuthJWT
|
||||||
summary: "Profile / 業務 Email 與 Phone 驗證 / TOTP MFA"
|
tags: "Member - 會員"
|
||||||
|
summary: "Profile / 業務 Email 與 Phone 驗證 / TOTP MFA(需 Bearer)"
|
||||||
)
|
)
|
||||||
service gateway {
|
service gateway {
|
||||||
@doc "取得當前會員 profile(Bearer JWT;本機 dev 可 fallback X-Tenant-ID + X-UID)"
|
@doc "取得當前會員 profile(Bearer JWT;本機 dev 可 fallback X-Tenant-ID + X-UID)"
|
||||||
|
|
|
||||||
|
|
@ -256,10 +256,11 @@ type (
|
||||||
)
|
)
|
||||||
|
|
||||||
@server(
|
@server(
|
||||||
group: permission
|
group: permission
|
||||||
prefix: /api/v1/permissions
|
prefix: /api/v1/permissions
|
||||||
tags: "Permission - 權限"
|
middleware: AuthJWT
|
||||||
summary: "Catalog / 角色 / 使用者角色 / 外部映射 / Policy"
|
tags: "Permission - 權限(讀取)"
|
||||||
|
summary: "Catalog / Me(需 Bearer,不過 RBAC 檢查)"
|
||||||
)
|
)
|
||||||
service gateway {
|
service gateway {
|
||||||
@doc "取得全局 Permission Catalog(樹狀或扁平;可篩 status/type)"
|
@doc "取得全局 Permission Catalog(樹狀或扁平;可篩 status/type)"
|
||||||
|
|
@ -295,7 +296,16 @@ service gateway {
|
||||||
*/
|
*/
|
||||||
@handler getMePermissions
|
@handler getMePermissions
|
||||||
get /me (MePermissionsQuery) returns (MePermissionsData)
|
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)"
|
@doc "列出租戶內所有角色(含 system role)"
|
||||||
/*
|
/*
|
||||||
@respdoc-200 (RoleListOKStatus) // 成功
|
@respdoc-200 (RoleListOKStatus) // 成功
|
||||||
|
|
|
||||||
|
|
@ -37,12 +37,6 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
||||||
Path: "/login/social/start",
|
Path: "/login/social/start",
|
||||||
Handler: auth.LoginSocialStartHandler(serverCtx),
|
Handler: auth.LoginSocialStartHandler(serverCtx),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
// 登出(撤銷 access JWT 及配對 refresh JWT)
|
|
||||||
Method: http.MethodPost,
|
|
||||||
Path: "/logout",
|
|
||||||
Handler: auth.LogoutHandler(serverCtx),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
// Email 註冊(建立 ZITADEL + member,寄 registration OTP)
|
// Email 註冊(建立 ZITADEL + member,寄 registration OTP)
|
||||||
Method: http.MethodPost,
|
Method: http.MethodPost,
|
||||||
|
|
@ -90,80 +84,98 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
||||||
)
|
)
|
||||||
|
|
||||||
server.AddRoutes(
|
server.AddRoutes(
|
||||||
[]rest.Route{
|
rest.WithMiddlewares(
|
||||||
{
|
[]rest.Middleware{serverCtx.AuthJWT},
|
||||||
// 取得當前會員 profile(Bearer JWT;本機 dev 可 fallback X-Tenant-ID + X-UID)
|
[]rest.Route{
|
||||||
Method: http.MethodGet,
|
{
|
||||||
Path: "/me",
|
// 登出(撤銷 access JWT 及配對 refresh JWT)
|
||||||
Handler: member.GetMemberMeHandler(serverCtx),
|
Method: http.MethodPost,
|
||||||
},
|
Path: "/logout",
|
||||||
{
|
Handler: auth.LogoutHandler(serverCtx),
|
||||||
// 更新當前會員 profile
|
},
|
||||||
Method: http.MethodPatch,
|
}...,
|
||||||
Path: "/me",
|
),
|
||||||
Handler: member.UpdateMemberMeHandler(serverCtx),
|
rest.WithPrefix("/api/v1/auth"),
|
||||||
},
|
)
|
||||||
{
|
|
||||||
// TOTP 狀態
|
server.AddRoutes(
|
||||||
Method: http.MethodGet,
|
rest.WithMiddlewares(
|
||||||
Path: "/me/totp",
|
[]rest.Middleware{serverCtx.AuthJWT},
|
||||||
Handler: member.GetTOTPStatusHandler(serverCtx),
|
[]rest.Route{
|
||||||
},
|
{
|
||||||
{
|
// 取得當前會員 profile(Bearer JWT;本機 dev 可 fallback X-Tenant-ID + X-UID)
|
||||||
// 解除 TOTP 綁定
|
Method: http.MethodGet,
|
||||||
Method: http.MethodDelete,
|
Path: "/me",
|
||||||
Path: "/me/totp",
|
Handler: member.GetMemberMeHandler(serverCtx),
|
||||||
Handler: member.DisableTOTPHandler(serverCtx),
|
},
|
||||||
},
|
{
|
||||||
{
|
// 更新當前會員 profile
|
||||||
// 重產 TOTP 備援碼
|
Method: http.MethodPatch,
|
||||||
Method: http.MethodPost,
|
Path: "/me",
|
||||||
Path: "/me/totp/backup-codes",
|
Handler: member.UpdateMemberMeHandler(serverCtx),
|
||||||
Handler: member.RegenerateTOTPBackupCodesHandler(serverCtx),
|
},
|
||||||
},
|
{
|
||||||
{
|
// TOTP 狀態
|
||||||
// 確認 TOTP 綁定
|
Method: http.MethodGet,
|
||||||
Method: http.MethodPost,
|
Path: "/me/totp",
|
||||||
Path: "/me/totp/enroll-confirm",
|
Handler: member.GetTOTPStatusHandler(serverCtx),
|
||||||
Handler: member.ConfirmTOTPEnrollHandler(serverCtx),
|
},
|
||||||
},
|
{
|
||||||
{
|
// 解除 TOTP 綁定
|
||||||
// 開始 TOTP 綁定
|
Method: http.MethodDelete,
|
||||||
Method: http.MethodPost,
|
Path: "/me/totp",
|
||||||
Path: "/me/totp/enroll-start",
|
Handler: member.DisableTOTPHandler(serverCtx),
|
||||||
Handler: member.StartTOTPEnrollHandler(serverCtx),
|
},
|
||||||
},
|
{
|
||||||
{
|
// 重產 TOTP 備援碼
|
||||||
// 驗證 TOTP(step-up 測試)
|
Method: http.MethodPost,
|
||||||
Method: http.MethodPost,
|
Path: "/me/totp/backup-codes",
|
||||||
Path: "/me/totp/verify",
|
Handler: member.RegenerateTOTPBackupCodesHandler(serverCtx),
|
||||||
Handler: member.VerifyTOTPHandler(serverCtx),
|
},
|
||||||
},
|
{
|
||||||
{
|
// 確認 TOTP 綁定
|
||||||
// 確認業務 email 驗證
|
Method: http.MethodPost,
|
||||||
Method: http.MethodPost,
|
Path: "/me/totp/enroll-confirm",
|
||||||
Path: "/me/verifications/email/confirm",
|
Handler: member.ConfirmTOTPEnrollHandler(serverCtx),
|
||||||
Handler: member.ConfirmEmailVerificationHandler(serverCtx),
|
},
|
||||||
},
|
{
|
||||||
{
|
// 開始 TOTP 綁定
|
||||||
// 開始業務 email 驗證
|
Method: http.MethodPost,
|
||||||
Method: http.MethodPost,
|
Path: "/me/totp/enroll-start",
|
||||||
Path: "/me/verifications/email/start",
|
Handler: member.StartTOTPEnrollHandler(serverCtx),
|
||||||
Handler: member.StartEmailVerificationHandler(serverCtx),
|
},
|
||||||
},
|
{
|
||||||
{
|
// 驗證 TOTP(step-up 測試)
|
||||||
// 確認業務 phone 驗證
|
Method: http.MethodPost,
|
||||||
Method: http.MethodPost,
|
Path: "/me/totp/verify",
|
||||||
Path: "/me/verifications/phone/confirm",
|
Handler: member.VerifyTOTPHandler(serverCtx),
|
||||||
Handler: member.ConfirmPhoneVerificationHandler(serverCtx),
|
},
|
||||||
},
|
{
|
||||||
{
|
// 確認業務 email 驗證
|
||||||
// 開始業務 phone 驗證
|
Method: http.MethodPost,
|
||||||
Method: http.MethodPost,
|
Path: "/me/verifications/email/confirm",
|
||||||
Path: "/me/verifications/phone/start",
|
Handler: member.ConfirmEmailVerificationHandler(serverCtx),
|
||||||
Handler: member.StartPhoneVerificationHandler(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"),
|
rest.WithPrefix("/api/v1/members"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -181,98 +193,110 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
||||||
)
|
)
|
||||||
|
|
||||||
server.AddRoutes(
|
server.AddRoutes(
|
||||||
[]rest.Route{
|
rest.WithMiddlewares(
|
||||||
{
|
[]rest.Middleware{serverCtx.AuthJWT},
|
||||||
// 取得全局 Permission Catalog(樹狀或扁平;可篩 status/type)
|
[]rest.Route{
|
||||||
Method: http.MethodGet,
|
{
|
||||||
Path: "/catalog",
|
// 取得全局 Permission Catalog(樹狀或扁平;可篩 status/type)
|
||||||
Handler: permission.GetPermissionCatalogHandler(serverCtx),
|
Method: http.MethodGet,
|
||||||
},
|
Path: "/catalog",
|
||||||
{
|
Handler: permission.GetPermissionCatalogHandler(serverCtx),
|
||||||
// 取得當前使用者的 role / permission map(前端渲染選單)
|
},
|
||||||
Method: http.MethodGet,
|
{
|
||||||
Path: "/me",
|
// 取得當前使用者的 role / permission map(前端渲染選單)
|
||||||
Handler: permission.GetMePermissionsHandler(serverCtx),
|
Method: http.MethodGet,
|
||||||
},
|
Path: "/me",
|
||||||
{
|
Handler: permission.GetMePermissionsHandler(serverCtx),
|
||||||
// 強制重載 Casbin policy(單租戶或所有租戶;同步 + Pub/Sub broadcast)
|
},
|
||||||
Method: http.MethodPost,
|
}...,
|
||||||
Path: "/policy/reload",
|
),
|
||||||
Handler: permission.ReloadPolicyHandler(serverCtx),
|
rest.WithPrefix("/api/v1/permissions"),
|
||||||
},
|
)
|
||||||
{
|
|
||||||
// 列出外部來源 → 內部 role 的映射(zitadel / ldap / scim)
|
server.AddRoutes(
|
||||||
Method: http.MethodGet,
|
rest.WithMiddlewares(
|
||||||
Path: "/role-mappings",
|
[]rest.Middleware{serverCtx.AuthJWT, serverCtx.CasbinRBAC},
|
||||||
Handler: permission.ListRoleMappingsHandler(serverCtx),
|
[]rest.Route{
|
||||||
},
|
{
|
||||||
{
|
// 強制重載 Casbin policy(單租戶或所有租戶;同步 + Pub/Sub broadcast)
|
||||||
// Upsert 外部 IdP 群組到內部 role 的映射
|
Method: http.MethodPost,
|
||||||
Method: http.MethodPut,
|
Path: "/policy/reload",
|
||||||
Path: "/role-mappings",
|
Handler: permission.ReloadPolicyHandler(serverCtx),
|
||||||
Handler: permission.UpsertRoleMappingHandler(serverCtx),
|
},
|
||||||
},
|
{
|
||||||
{
|
// 列出外部來源 → 內部 role 的映射(zitadel / ldap / scim)
|
||||||
// 刪除外部 → 內部 role 映射
|
Method: http.MethodGet,
|
||||||
Method: http.MethodDelete,
|
Path: "/role-mappings",
|
||||||
Path: "/role-mappings",
|
Handler: permission.ListRoleMappingsHandler(serverCtx),
|
||||||
Handler: permission.DeleteRoleMappingHandler(serverCtx),
|
},
|
||||||
},
|
{
|
||||||
{
|
// Upsert 外部 IdP 群組到內部 role 的映射
|
||||||
// 列出租戶內所有角色(含 system role)
|
Method: http.MethodPut,
|
||||||
Method: http.MethodGet,
|
Path: "/role-mappings",
|
||||||
Path: "/roles",
|
Handler: permission.UpsertRoleMappingHandler(serverCtx),
|
||||||
Handler: permission.ListRolesHandler(serverCtx),
|
},
|
||||||
},
|
{
|
||||||
{
|
// 刪除外部 → 內部 role 映射
|
||||||
// 建立租戶自訂角色(key 不可改、不可使用 system./platform_ 開頭)
|
Method: http.MethodDelete,
|
||||||
Method: http.MethodPost,
|
Path: "/role-mappings",
|
||||||
Path: "/roles",
|
Handler: permission.DeleteRoleMappingHandler(serverCtx),
|
||||||
Handler: permission.CreateRoleHandler(serverCtx),
|
},
|
||||||
},
|
{
|
||||||
{
|
// 列出租戶內所有角色(含 system role)
|
||||||
// 更新角色(display_name / status;is_system 角色不可改 status)
|
Method: http.MethodGet,
|
||||||
Method: http.MethodPatch,
|
Path: "/roles",
|
||||||
Path: "/roles/:id",
|
Handler: permission.ListRolesHandler(serverCtx),
|
||||||
Handler: permission.UpdateRoleHandler(serverCtx),
|
},
|
||||||
},
|
{
|
||||||
{
|
// 建立租戶自訂角色(key 不可改、不可使用 system./platform_ 開頭)
|
||||||
// 刪除角色(is_system 不可刪;存在 user 指派時拒絕)
|
Method: http.MethodPost,
|
||||||
Method: http.MethodDelete,
|
Path: "/roles",
|
||||||
Path: "/roles/:id",
|
Handler: permission.CreateRoleHandler(serverCtx),
|
||||||
Handler: permission.DeleteRoleHandler(serverCtx),
|
},
|
||||||
},
|
{
|
||||||
{
|
// 更新角色(display_name / status;is_system 角色不可改 status)
|
||||||
// 讀取角色目前勾選的 permission 集合
|
Method: http.MethodPatch,
|
||||||
Method: http.MethodGet,
|
Path: "/roles/:id",
|
||||||
Path: "/roles/:id/permissions",
|
Handler: permission.UpdateRoleHandler(serverCtx),
|
||||||
Handler: permission.GetRolePermissionsHandler(serverCtx),
|
},
|
||||||
},
|
{
|
||||||
{
|
// 刪除角色(is_system 不可刪;存在 user 指派時拒絕)
|
||||||
// 全量取代角色的 permission 勾選(自動補齊父權限;觸發 LoadPolicy + Pub/Sub reload)
|
Method: http.MethodDelete,
|
||||||
Method: http.MethodPut,
|
Path: "/roles/:id",
|
||||||
Path: "/roles/:id/permissions",
|
Handler: permission.DeleteRoleHandler(serverCtx),
|
||||||
Handler: permission.ReplaceRolePermissionsHandler(serverCtx),
|
},
|
||||||
},
|
{
|
||||||
{
|
// 讀取角色目前勾選的 permission 集合
|
||||||
// 查詢使用者目前指派的角色(含 RoleKey / DisplayName)
|
Method: http.MethodGet,
|
||||||
Method: http.MethodGet,
|
Path: "/roles/:id/permissions",
|
||||||
Path: "/users/:uid/roles",
|
Handler: permission.GetRolePermissionsHandler(serverCtx),
|
||||||
Handler: permission.ListUserRolesHandler(serverCtx),
|
},
|
||||||
},
|
{
|
||||||
{
|
// 全量取代角色的 permission 勾選(自動補齊父權限;觸發 LoadPolicy + Pub/Sub reload)
|
||||||
// 指派角色給使用者(預設 source=manual;source 來源由 SyncFromX 自動標)
|
Method: http.MethodPut,
|
||||||
Method: http.MethodPost,
|
Path: "/roles/:id/permissions",
|
||||||
Path: "/users/:uid/roles",
|
Handler: permission.ReplaceRolePermissionsHandler(serverCtx),
|
||||||
Handler: permission.AssignUserRoleHandler(serverCtx),
|
},
|
||||||
},
|
{
|
||||||
{
|
// 查詢使用者目前指派的角色(含 RoleKey / DisplayName)
|
||||||
// 撤銷使用者的單一角色
|
Method: http.MethodGet,
|
||||||
Method: http.MethodDelete,
|
Path: "/users/:uid/roles",
|
||||||
Path: "/users/:uid/roles/:role_id",
|
Handler: permission.ListUserRolesHandler(serverCtx),
|
||||||
Handler: permission.RevokeUserRoleHandler(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"),
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
|
"gateway/internal/library/actor"
|
||||||
)
|
)
|
||||||
|
|
||||||
type actorKey struct{}
|
// Actor aliases library/actor.Actor so existing logic code keeps
|
||||||
|
// referring to `member.Actor` without an import change.
|
||||||
// Actor identifies the calling member (JWT middleware or dev headers).
|
type Actor = actor.Actor
|
||||||
type Actor struct {
|
|
||||||
TenantID string
|
|
||||||
UID string
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithActor stores tenant/uid on the context for member logic handlers.
|
// 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 {
|
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) {
|
func ActorFromContext(ctx context.Context) (Actor, error) {
|
||||||
v, ok := ctx.Value(actorKey{}).(Actor)
|
return actor.ActorFromContext(ctx)
|
||||||
if !ok || v.TenantID == "" || v.UID == "" {
|
|
||||||
return Actor{}, fmt.Errorf("missing bearer token or X-Tenant-ID/X-UID headers")
|
|
||||||
}
|
|
||||||
return v, nil
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,31 +2,21 @@ package permission
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
|
"gateway/internal/library/actor"
|
||||||
)
|
)
|
||||||
|
|
||||||
type actorKey struct{}
|
// Actor aliases library/actor.Actor so existing permission logic keeps
|
||||||
|
// referring to `permission.Actor` without an import change.
|
||||||
// Actor identifies the calling tenant member (Bearer JWT or dev headers).
|
type Actor = actor.Actor
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithActor stores tenant/uid on the context for permission logic
|
// 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 {
|
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
|
// ActorFromContext reads the actor injected by AuthJWT middleware.
|
||||||
// headers.
|
|
||||||
func ActorFromContext(ctx context.Context) (Actor, error) {
|
func ActorFromContext(ctx context.Context) (Actor, error) {
|
||||||
v, ok := ctx.Value(actorKey{}).(Actor)
|
return actor.ActorFromContext(ctx)
|
||||||
if !ok || v.TenantID == "" || v.UID == "" {
|
|
||||||
return Actor{}, fmt.Errorf("missing bearer token or X-Tenant-ID/X-UID headers")
|
|
||||||
}
|
|
||||||
return v, nil
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
redislib "gateway/internal/library/redis"
|
||||||
"gateway/internal/library/validate"
|
"gateway/internal/library/validate"
|
||||||
"gateway/internal/library/zitadel"
|
"gateway/internal/library/zitadel"
|
||||||
|
"gateway/internal/middleware"
|
||||||
authdomrepo "gateway/internal/model/auth/domain/repository"
|
authdomrepo "gateway/internal/model/auth/domain/repository"
|
||||||
domauth "gateway/internal/model/auth/domain/usecase"
|
domauth "gateway/internal/model/auth/domain/usecase"
|
||||||
authrepo "gateway/internal/model/auth/repository"
|
authrepo "gateway/internal/model/auth/repository"
|
||||||
|
|
@ -24,6 +25,8 @@ import (
|
||||||
domperm "gateway/internal/model/permission/domain/usecase"
|
domperm "gateway/internal/model/permission/domain/usecase"
|
||||||
permusecase "gateway/internal/model/permission/usecase"
|
permusecase "gateway/internal/model/permission/usecase"
|
||||||
"gateway/internal/worker/notification_retry"
|
"gateway/internal/worker/notification_retry"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ServiceContext struct {
|
type ServiceContext struct {
|
||||||
|
|
@ -58,6 +61,12 @@ type ServiceContext struct {
|
||||||
PermissionRBAC domperm.RBACUseCase
|
PermissionRBAC domperm.RBACUseCase
|
||||||
PermissionRoleRepo dompermrepo.RoleRepository
|
PermissionRoleRepo dompermrepo.RoleRepository
|
||||||
permissionModule *permusecase.Module
|
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 {
|
func NewServiceContext(c config.Config) *ServiceContext {
|
||||||
|
|
@ -158,6 +167,13 @@ func NewServiceContext(c config.Config) *ServiceContext {
|
||||||
sc.PermissionRoleRepo = permMod.Roles
|
sc.PermissionRoleRepo = permMod.Roles
|
||||||
sc.permissionModule = permMod
|
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
|
return sc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue