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:
王性驊 2026-05-21 17:30:50 +08:00
parent 4590f1c951
commit bdeb7e8263
15 changed files with 610 additions and 359 deletions

View File

@ -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

View File

@ -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)

View File

@ -94,6 +94,112 @@ go build ./... # 確保編譯通過
驗證:把 `docs/openapi/gateway.yaml` 丟進 https://editor.swagger.io 或 Swagger UI確認分組、description、enum 都正確顯示。
## Middlewarego-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 ... }
// 需登入 + RBACAuthJWT,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"),
)
```
### 撰寫新 middlewarego-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` 輸出:

View File

@ -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

View File

@ -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 "取得當前會員 profileBearer JWT本機 dev 可 fallback X-Tenant-ID + X-UID"

View File

@ -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) // 成功

View File

@ -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{
{
// 取得當前會員 profileBearer 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),
},
{
// 驗證 TOTPstep-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{
{
// 取得當前會員 profileBearer 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),
},
{
// 驗證 TOTPstep-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 / statusis_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=manualsource 來源由 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 / statusis_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=manualsource 來源由 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"),
)
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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))
}

View File

@ -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))
}

View File

@ -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)
}
}
}

View File

@ -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)
}
}

View File

@ -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
}