diff --git a/.gitignore b/.gitignore
index 4f51d21..ef56624 100644
--- a/.gitignore
+++ b/.gitignore
@@ -73,5 +73,7 @@ temp/
# =========================
# Go workspace(本機多模組開發用)
# =========================
-go.work
-go.work.sum
+# E2E 產物(make e2e-full / e2e-up 生成)
+test/e2e/fixtures/state.json
+test/e2e/fixtures/gateway.pid
+.cache/
diff --git a/Makefile b/Makefile
index e9ba5ea..537d579 100644
--- a/Makefile
+++ b/Makefile
@@ -15,7 +15,7 @@ GOLANGCI_PKG := github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
.DEFAULT_GOAL := help
-.PHONY: help tools gen-api gen-mock build-go-doc gen-doc test fmt lint lint-fix fix check run \
+.PHONY: help tools gen-api gen-mock build-go-doc gen-doc test test-e2e e2e-full e2e-casbin e2e-up e2e-down fmt lint lint-fix fix check run \
deps-up deps-up-smtp deps-down deps-down-v deps-logs deps-ps mongo-index notify-test totp-test member-seed setup-dev run-local
help: ## 顯示可用指令
@@ -58,6 +58,24 @@ gen-doc: build-go-doc ## 從 .api 生成 OpenAPI 3.0 YAML
test: ## 執行測試
$(GO) test ./...
+test-e2e: ## 對已啟動的 Gateway 跑 E2E(需 state.json;見 docs/e2e-testing.md)
+ GATEWAY_E2E=1 $(GO) test -tags=e2e -v -count=1 ./test/e2e/... -run 'Test(Auth_|Health|Member|Permission)'
+ GATEWAY_E2E=1 $(GO) test -tags=e2e -v -count=1 ./test/e2e/... -run 'TestZZZ_AuthTokenRefreshAndLogout'
+
+e2e-full: ## 全新 Docker + index + seed + E2E + 關閉(一鍵完整測試)
+ bash scripts/e2e-run.sh
+
+e2e-casbin: ## 全新 Docker + Casbin enabled + E2E + 關閉(含 RBAC 403 / reload)
+ E2E_CASBIN=1 E2E_CONFIG=test/e2e/fixtures/e2e.casbin.yaml \
+ E2E_TEST_PATTERN='Test(Auth_|Health|Member|Permission_(Catalog|Me|CasbinRBAC))' \
+ bash scripts/e2e-run.sh
+
+e2e-up: ## 起 Docker + index + seed + Gateway(不跑測試、不 teardown)
+ bash scripts/e2e-up.sh
+
+e2e-down: ## 停止 E2E Gateway 並 docker compose down -v
+ bash scripts/e2e-down.sh
+
fmt: ## gofmt + goimports(不含 lint)
$(GOFMT) -s -w $(GOFILES)
@command -v goimports >/dev/null 2>&1 && goimports -w . || (echo "goimports not found; run: make tools" && exit 1)
diff --git a/cmd/e2e-seed/main.go b/cmd/e2e-seed/main.go
new file mode 100644
index 0000000..6508df8
--- /dev/null
+++ b/cmd/e2e-seed/main.go
@@ -0,0 +1,245 @@
+// Command e2e-seed prepares a fresh E2E tenant, member, permission roles, and JWT tokens.
+//
+// Usage (usually invoked by scripts/e2e-run.sh):
+//
+// go run ./cmd/e2e-seed -f test/e2e/fixtures/e2e.yaml -out test/e2e/fixtures/state.json
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "os"
+ "time"
+
+ "gateway/internal/config"
+ redislib "gateway/internal/library/redis"
+ domauth "gateway/internal/model/auth/domain/usecase"
+ authrepo "gateway/internal/model/auth/repository"
+ authusecase "gateway/internal/model/auth/usecase"
+ dommember "gateway/internal/model/member/domain/usecase"
+ memberusecase "gateway/internal/model/member/usecase"
+ "gateway/internal/model/permission/domain/enum"
+ domperm "gateway/internal/model/permission/domain/usecase"
+ permrepo "gateway/internal/model/permission/repository"
+ permseed "gateway/internal/model/permission/seed"
+ permusecase "gateway/internal/model/permission/usecase"
+
+ "github.com/zeromicro/go-zero/core/conf"
+)
+
+const (
+ defaultTenantID = "e2e-tenant"
+ defaultSlug = "e2e"
+ defaultPrefix = "E2E"
+ defaultEmail = "e2e-owner@example.com"
+ defaultNoRoleEmail = "e2e-no-role@example.com"
+ defaultRoleKey = "tenant_owner"
+)
+
+var (
+ configFile = flag.String("f", "test/e2e/fixtures/e2e.yaml", "config file")
+ outFile = flag.String("out", "test/e2e/fixtures/state.json", "output fixture JSON")
+ tenantID = flag.String("tenant", defaultTenantID, "tenant_id")
+ slug = flag.String("slug", defaultSlug, "tenant slug")
+ uidPrefix = flag.String("prefix", defaultPrefix, "uid prefix")
+ email = flag.String("email", defaultEmail, "member email")
+ roleKey = flag.String("role", defaultRoleKey, "system role key to assign")
+)
+
+// State is consumed by test/e2e HTTP tests.
+type State struct {
+ BaseURL string `json:"base_url"`
+ TenantID string `json:"tenant_id"`
+ TenantSlug string `json:"tenant_slug"`
+ UID string `json:"uid"`
+ Email string `json:"email"`
+ AccessToken string `json:"access_token"`
+ RefreshToken string `json:"refresh_token"`
+ RoleKey string `json:"role_key"`
+ NoRoleUID string `json:"no_role_uid"`
+ NoRoleEmail string `json:"no_role_email"`
+ NoRoleAccessToken string `json:"no_role_access_token"`
+ NoRoleRefreshToken string `json:"no_role_refresh_token"`
+}
+
+func main() {
+ if err := run(); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+}
+
+func run() error {
+ flag.Parse()
+
+ var c config.Config
+ conf.MustLoad(*configFile, &c)
+ if c.Mongo.Host == "" || c.Redis.Host == "" {
+ return fmt.Errorf("e2e-seed: Mongo and Redis are required")
+ }
+ if !c.Auth.Defaults().Enabled() {
+ return fmt.Errorf("e2e-seed: Auth JWT secrets are required")
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
+ defer cancel()
+
+ rds, err := redislib.NewClient(c.Redis)
+ if err != nil {
+ return fmt.Errorf("e2e-seed: redis: %w", err)
+ }
+
+ memberMod, err := memberusecase.NewModuleFromParam(memberusecase.ModuleParam{
+ Redis: rds,
+ MongoConf: &c.Mongo,
+ Config: c.Member,
+ })
+ if err != nil {
+ return fmt.Errorf("e2e-seed: member module: %w", err)
+ }
+ if memberMod.Tenant == nil || memberMod.Lifecycle == nil || memberMod.Profile == nil {
+ return fmt.Errorf("e2e-seed: member module incomplete (need Mongo)")
+ }
+
+ if _, err := memberMod.Tenant.Create(ctx, &dommember.CreateTenantRequest{
+ TenantID: *tenantID,
+ Slug: *slug,
+ Name: "E2E Tenant",
+ UIDPrefix: *uidPrefix,
+ }); err != nil {
+ fmt.Printf("e2e-seed: tenant create skipped (may exist): %v\n", err)
+ }
+
+ uid, err := ensureMember(ctx, memberMod, *tenantID, *email)
+ if err != nil {
+ return err
+ }
+
+ if err := seedPermissionAndAssignRole(ctx, c, *tenantID, uid, *roleKey); err != nil {
+ return err
+ }
+
+ noRoleUID, err := ensureMemberWithZitadel(ctx, memberMod, *tenantID, defaultNoRoleEmail, "e2e-no-role-sub", "E2E No Role")
+ if err != nil {
+ return err
+ }
+
+ tokens := authusecase.MustTokenUseCase(authusecase.TokenUseCaseParam{
+ Config: c.Auth,
+ Revoke: authrepo.NewRedisTokenRevokeStore(rds),
+ })
+ pair, err := issueTokenPair(ctx, tokens, *tenantID, uid)
+ if err != nil {
+ return fmt.Errorf("e2e-seed: issue token: %w", err)
+ }
+ noRolePair, err := issueTokenPair(ctx, tokens, *tenantID, noRoleUID)
+ if err != nil {
+ return fmt.Errorf("e2e-seed: issue no-role token: %w", err)
+ }
+
+ state := State{
+ BaseURL: fmt.Sprintf("http://127.0.0.1:%d", c.Port),
+ TenantID: *tenantID,
+ TenantSlug: *slug,
+ UID: uid,
+ Email: *email,
+ AccessToken: pair.AccessToken,
+ RefreshToken: pair.RefreshToken,
+ RoleKey: *roleKey,
+ NoRoleUID: noRoleUID,
+ NoRoleEmail: defaultNoRoleEmail,
+ NoRoleAccessToken: noRolePair.AccessToken,
+ NoRoleRefreshToken: noRolePair.RefreshToken,
+ }
+ raw, err := json.MarshalIndent(state, "", " ")
+ if err != nil {
+ return fmt.Errorf("e2e-seed: marshal state: %w", err)
+ }
+ if err := os.WriteFile(*outFile, raw, 0o600); err != nil {
+ return fmt.Errorf("e2e-seed: write %s: %w", *outFile, err)
+ }
+
+ fmt.Printf("e2e-seed: tenant=%s uid=%s role=%s\n", state.TenantID, state.UID, state.RoleKey)
+ fmt.Printf("e2e-seed: wrote %s (base_url=%s)\n", *outFile, state.BaseURL)
+ return nil
+}
+
+func issueTokenPair(ctx context.Context, tokens domauth.TokenUseCase, tenantID, uid string) (*domauth.TokenPair, error) {
+ return tokens.IssuePair(ctx, &domauth.IssuePairRequest{
+ TenantID: tenantID,
+ UID: uid,
+ })
+}
+
+func ensureMember(ctx context.Context, mod *memberusecase.Module, tenantID, email string) (string, error) {
+ return ensureMemberWithZitadel(ctx, mod, tenantID, email, "", "E2E Owner")
+}
+
+func ensureMemberWithZitadel(ctx context.Context, mod *memberusecase.Module, tenantID, email, zitadelUserID, displayName string) (string, error) {
+ m, err := mod.Lifecycle.CreateUnverified(ctx, &dommember.CreatePlatformMemberRequest{
+ TenantID: tenantID,
+ Email: email,
+ ZitadelUserID: zitadelUserID,
+ DisplayName: displayName,
+ Language: "zh-tw",
+ })
+ if err == nil {
+ if actErr := mod.Lifecycle.Activate(ctx, tenantID, m.UID); actErr != nil {
+ return "", fmt.Errorf("e2e-seed: activate member: %w", actErr)
+ }
+ return m.UID, nil
+ }
+
+ // Idempotent re-run: find existing member by listing (dev tenant has one owner).
+ list, listErr := mod.Profile.List(ctx, &dommember.ListMembersRequest{
+ TenantID: tenantID,
+ Limit: 50,
+ })
+ if listErr != nil {
+ return "", fmt.Errorf("e2e-seed: create member: %w (list fallback: %v)", err, listErr)
+ }
+ for _, item := range list.Items {
+ if item.ZitadelEmail == email || item.BusinessEmail == email {
+ return item.UID, nil
+ }
+ }
+ return "", fmt.Errorf("e2e-seed: create member: %w", err)
+}
+
+func seedPermissionAndAssignRole(ctx context.Context, c config.Config, tenantID, uid, roleKey string) error {
+ perms := permrepo.NewPermissionRepository(permrepo.PermissionRepositoryParam{Conf: &c.Mongo})
+ roles := permrepo.NewRoleRepository(permrepo.RoleRepositoryParam{Conf: &c.Mongo})
+ rolePerms := permrepo.NewRolePermissionRepository(permrepo.RolePermissionRepositoryParam{Conf: &c.Mongo})
+
+ if _, err := permseed.Apply(ctx, perms, roles, rolePerms, permseed.ApplyOptions{
+ TenantIDs: []string{tenantID},
+ }); err != nil {
+ return fmt.Errorf("e2e-seed: permission seed: %w", err)
+ }
+
+ permMod, err := permusecase.NewModuleFromParam(permusecase.FactoryParam{
+ MongoConf: &c.Mongo,
+ Redis: nil,
+ Config: c.Permission,
+ })
+ if err != nil {
+ return fmt.Errorf("e2e-seed: permission module: %w", err)
+ }
+
+ role, err := permMod.Role.GetByKey(ctx, tenantID, roleKey)
+ if err != nil {
+ return fmt.Errorf("e2e-seed: get role %q: %w", roleKey, err)
+ }
+ if _, err := permMod.UserRole.Assign(ctx, &domperm.AssignParam{
+ TenantID: tenantID,
+ UID: uid,
+ RoleID: role.ID.Hex(),
+ Source: enum.RoleSourceManual,
+ }); err != nil {
+ // Idempotent re-run when role already assigned.
+ fmt.Printf("e2e-seed: assign role skipped: %v\n", err)
+ }
+ return nil
+}
diff --git a/docs/e2e-testing.md b/docs/e2e-testing.md
new file mode 100644
index 0000000..e2c756b
--- /dev/null
+++ b/docs/e2e-testing.md
@@ -0,0 +1,265 @@
+# Gateway E2E 測試指南
+
+本文件列出 **所有 HTTP API 測試情境** 與 **一鍵跑完整 E2E** 的方式。自動化測試程式在 `test/e2e/`(build tag: `e2e`)。
+
+---
+
+## 一鍵完整測試(推薦)
+
+從 **全新 Docker volume** 開始,依序:起 Mongo/Redis → 建 index → seed 資料 → 起 Gateway → 跑 E2E → **關閉並刪除 volume**。
+
+```bash
+cd gateway
+make e2e-full
+```
+
+等同於:
+
+```bash
+bash scripts/e2e-run.sh
+```
+
+成功時最後一行:`>> E2E OK`
+
+### 流程圖
+
+```mermaid
+flowchart LR
+ A[docker compose down -v] --> B[up mongo + redis]
+ B --> C[cmd/mongo-index]
+ C --> D[cmd/e2e-seed]
+ D --> E[gateway :18888]
+ E --> F[go test -tags=e2e]
+ F --> G[stop gateway + down -v]
+```
+
+### 其他指令
+
+| 指令 | 用途 |
+|------|------|
+| `make e2e-up` | 起環境 + seed + Gateway,**不跑測試**(本機除錯) |
+| `make test-e2e` | 對**已啟動**的 Gateway 跑 E2E(需先有 `state.json`) |
+| `make e2e-casbin` | 以 `Permission.Casbin.Enabled: true` 跑 RBAC reload / deny E2E |
+| `make e2e-down` | 停 Gateway + `docker compose down -v` |
+| `E2E_KEEP_DOCKER=1 make e2e-full` | 測完**保留** Docker(方便查 Mongo/Redis) |
+
+### E2E 專用設定
+
+| 檔案 | 說明 |
+|------|------|
+| `test/e2e/fixtures/e2e.yaml` | Port **18888**、DB **gateway_e2e**、Notification mock、Casbin **關閉** |
+| `test/e2e/fixtures/e2e.casbin.yaml` | 同上,但 Casbin **開啟**,搭配 `make e2e-casbin` |
+| `test/e2e/fixtures/state.json` | seed 產生的 tenant / uid / JWT(gitignore,執行後生成) |
+
+---
+
+## 測試覆蓋矩陣(一目瞭然)
+
+圖例:**✅ 自動 E2E** · **⏭ 需 ZITADEL / 手動** · **🔧 基礎設施**
+
+### 基礎設施
+
+| ID | 情境 | 自動 | 測試檔 |
+|----|------|:----:|--------|
+| INF-01 | Mongo + Redis docker healthy | ✅ | `scripts/e2e-run.sh` |
+| INF-02 | 全模組 Mongo index | ✅ | `cmd/mongo-index` |
+| INF-03 | E2E tenant + member + permission + JWT seed | ✅ | `cmd/e2e-seed` |
+| INF-04 | Gateway 監聽 :18888 | ✅ | `scripts/e2e-run.sh` |
+
+### Normal
+
+| ID | Method | Path | 情境 | 自動 | 測試 |
+|----|--------|------|------|:----:|------|
+| N-01 | GET | `/api/v1/health` | Ping 200 | ✅ | `TestHealth_Ping` |
+| N-02 | GET | `/api/v1/health` | 無需 Bearer | ✅ | `TestHealth_NoAuthRequired` |
+
+### Auth(`/api/v1/auth`)
+
+| ID | Method | Path | 情境 | 自動 | 測試 / 備註 |
+|----|--------|------|------|:----:|-------------|
+| A-01 | POST | `/register` | Email 註冊 | ⏭ | 需 **ZITADEL** + invite |
+| A-02 | POST | `/register/confirm` | OTP 確認 | ⏭ | 同上 |
+| A-03 | POST | `/register/resend` | 重發 OTP | ⏭ | 同上 |
+| A-04 | POST | `/register/social/start` | 社交註冊 | ⏭ | 需 ZITADEL OAuth |
+| A-05 | GET | `/register/social/callback` | 社交註冊 callback | ⏭ | 需 ZITADEL |
+| A-06 | POST | `/login` | 密碼登入 | ⏭ | 需 ZITADEL ROPG |
+| A-07 | POST | `/login/social/start` | 社交登入 | ⏭ | 需 ZITADEL |
+| A-08 | GET | `/login/social/callback` | 社交登入 callback | ⏭ | 需 ZITADEL |
+| A-09 | POST | `/token/exchange` | id_token 換 JWT | ⏭ | 需 ZITADEL |
+| A-10 | POST | `/token/refresh` | 刷新 token | ✅ | `TestZZZ_AuthTokenRefreshAndLogout`(最後跑) |
+| A-11 | POST | `/logout` | 登出黑名單 jti | ✅ | 同上(同一測試內連續驗證) |
+| A-12 | GET | `/members/me`(無 Bearer) | 401 | ✅ | `TestAuth_MissingBearer_401` |
+| A-13 | POST | `/register`、`/login`、`/token/refresh`、`/login/social/start` | 公開 Auth validation 400 | ✅ | `TestAuth_PublicValidationErrors`(不需 ZITADEL) |
+
+> E2E 透過 `cmd/e2e-seed` 直接核發 JWT,不走 ZITADEL,因此 A-01~A-09 列為手動/staging 測試。
+
+### Member(`/api/v1/members`,需 Bearer)
+
+| ID | Method | Path | 情境 | 自動 | 測試 |
+|----|--------|------|------|:----:|------|
+| M-01 | GET | `/me` | 讀 profile | ✅ | `TestMember_GetMe` |
+| M-02 | PATCH | `/me` | 更新 display_name | ✅ | `TestMember_UpdateMe` |
+| M-03 | POST | `/me/verifications/email/start` | 發起 email OTP | ✅ | `TestMember_EmailVerification_FullFlow` |
+| M-04 | POST | `/me/verifications/email/confirm` | 確認 email OTP | ✅ | 同上(`GATEWAY_E2E=1` 從 Redis 取碼) |
+| M-05 | POST | `/me/verifications/phone/start` | 發起 phone OTP | ✅ | `TestMember_PhoneVerification_FullFlow` |
+| M-06 | POST | `/me/verifications/phone/confirm` | 確認 phone OTP | ✅ | 同上(`GATEWAY_E2E=1` 從 Redis 取碼) |
+| M-07 | GET | `/me/totp` | TOTP 狀態 | ✅ | `TestMember_TOTP_Status` |
+| M-08 | POST | `/me/totp/enroll-start` | 開始綁定 | ✅ | `TestMember_TOTP_FullFlow`(解析 `otpauth_url`) |
+| M-09 | POST | `/me/totp/enroll-confirm` | 確認綁定 | ✅ | 同上 |
+| M-10 | POST | `/me/totp/verify` | Step-up 驗碼 + replay 防護 | ✅ | 同上 |
+| M-11 | DELETE | `/me/totp` | 解除綁定 | ✅ | 同上 |
+| M-12 | POST | `/me/totp/backup-codes` | 重產備援碼 | ✅ | 同上 |
+
+### Permission(`/api/v1/permissions`,需 Bearer)
+
+| ID | Method | Path | Middleware | 情境 | 自動 | 測試 |
+|----|--------|------|------------|------|:----:|------|
+| P-01 | GET | `/catalog` | AuthJWT | 權限樹 | ✅ | `TestPermission_Catalog` |
+| P-02 | GET | `/me` | AuthJWT | 當前 user 權限 | ✅ | `TestPermission_Me` |
+| P-03 | GET | `/roles` | AuthJWT+Casbin* | 列角色 | ✅ | `TestPermission_RoleCRUD` |
+| P-04 | POST | `/roles` | AuthJWT+Casbin* | 建角色 | ✅ | 同上 |
+| P-05 | PATCH | `/roles/:id` | AuthJWT+Casbin* | 更新角色 | ✅ | 同上 |
+| P-06 | DELETE | `/roles/:id` | AuthJWT+Casbin* | 刪角色 | ✅ | 同上 |
+| P-07 | GET | `/roles/:id/permissions` | AuthJWT+Casbin* | 讀角色權限 | ✅ | `TestPermission_RolePermissions` |
+| P-08 | PUT | `/roles/:id/permissions` | AuthJWT+Casbin* | 取代角色權限 | ✅ | 同上 |
+| P-09 | GET | `/users/:uid/roles` | AuthJWT+Casbin* | 列 user 角色 | ✅ | `TestPermission_AssignUserRole` |
+| P-10 | POST | `/users/:uid/roles` | AuthJWT+Casbin* | 指派角色 | ✅ | 同上 |
+| P-11 | DELETE | `/users/:uid/roles/:role_id` | AuthJWT+Casbin* | 撤銷角色 | ✅ | 同上 |
+| P-12 | GET/PUT/DELETE | `/role-mappings` | AuthJWT+Casbin* | 外部映射 CRUD | ✅ | `TestPermission_RoleMappingCRUD` |
+| P-13 | POST | `/policy/reload` | AuthJWT+Casbin | 重載 policy | ✅ | `TestPermission_CasbinRBAC`(`make e2e-casbin`) |
+| P-14 | GET | `/roles` | AuthJWT+Casbin | no-role user RBAC denied 403 | ✅ | 同上 |
+
+\* 預設 `make e2e-full` 使用 `Permission.Casbin.Enabled: false`,Casbin middleware **放行**(rbac=nil passthrough)。`make e2e-casbin` 會改用 `e2e.casbin.yaml`,並額外驗證 policy reload 與 no-role 403。
+
+### Notification(無 HTTP API)
+
+| ID | 情境 | 自動 | 備註 |
+|----|------|:----:|------|
+| NT-01 | 同步 Send(mock email) | 🔧 | `make notify-test METHOD=email-send MOCK=1` |
+| NT-02 | 異步 Enqueue + Worker | 🔧 | `make notify-test METHOD=email-enqueue MOCK=1` |
+| NT-03 | Member email OTP 寄送 | ✅ | 含在 M-03/M-04(mock provider) |
+
+---
+
+## 統計摘要
+
+| 類別 | 自動 E2E | 待擴充 / 手動 |
+|------|:--------:|:-------------:|
+| Normal | 2 | 0 |
+| Auth | 4 | 9(ZITADEL) |
+| Member | 12 | 0 |
+| Permission | 14 | 0 |
+| **合計** | **32** | **9** |
+
+> Auth refresh/logout 會撤銷 JWT,因此腳本分兩輪跑:`member/permission` 先用 seed token,最後才跑 `TestZZZ_AuthTokenRefreshAndLogout`。
+
+---
+
+## 手動 / 延伸測試
+
+### Auth 全链路(需 ZITADEL)
+
+1. 設定 `etc/gateway.dev.yaml` 的 `Zitadel.*`
+2. `make deps-up && make run-dev`
+3. 依 `docs/auth-unified-registration.md` 跑 register → confirm → login
+
+### TOTP 互動測試
+
+```bash
+make deps-up
+make totp-test STEP=flow
+```
+
+### Notification
+
+見 [`docs/notification-testing.md`](notification-testing.md)。
+
+---
+
+## 環境變數
+
+| 變數 | 預設 | 說明 |
+|------|------|------|
+| `GATEWAY_E2E` | `1`(腳本內) | 開啟 OTP 寫入 Redis `e2e:otp:{challenge_id}` |
+| `E2E_STATE_FILE` | `test/e2e/fixtures/state.json` | seed 輸出路徑 |
+| `E2E_BASE_URL` | `http://127.0.0.1:18888` | 覆寫 Gateway URL |
+| `E2E_KEEP_DOCKER` | — | `1` = 測完不 down -v |
+| `GATEWAY_PORT` | `18888` | health check 用 |
+| `E2E_ROLE` | `tenant_owner` | 覆寫 seed 指派給主測試使用者的 system role |
+| `E2E_TEST_PATTERN` | `Test(Auth_\|Health\|Member\|Permission)` | 覆寫第一輪 `go test -run` pattern |
+| `E2E_CASBIN` | — | `1` = 執行 Casbin 專用 assertion(`make e2e-casbin` 已設定) |
+
+---
+
+## 目錄結構
+
+```
+gateway/
+├── cmd/e2e-seed/ # E2E 資料 + JWT seed
+├── scripts/
+│ ├── e2e-run.sh # 一鍵完整流程
+│ ├── e2e-up.sh # 只起環境
+│ └── e2e-down.sh # 關閉
+├── test/e2e/
+│ ├── fixtures/
+│ │ ├── e2e.yaml # E2E 設定
+│ │ ├── e2e.casbin.yaml # Casbin enabled E2E 設定
+│ │ └── state.json # 生成(gitignore)
+│ ├── client.go # HTTP helper
+│ ├── health_test.go
+│ ├── auth_test.go
+│ ├── member_test.go
+│ └── permission_test.go
+└── docs/e2e-testing.md # 本文件
+```
+
+---
+
+## CI 建議
+
+```yaml
+# 範例 GitHub Actions job
+- name: E2E
+ run: make e2e-full
+ working-directory: gateway
+
+- name: E2E Casbin
+ run: make e2e-casbin
+ working-directory: gateway
+```
+
+PR 門檻:`make check`(unit)+ `make e2e-full`(整合)+ `make e2e-casbin`(RBAC enforcement)。
+
+---
+
+## 常見問題
+
+**Q: `missing e2e otp for challenge`**
+
+A: Gateway 必須以 `GATEWAY_E2E=1` 啟動(`e2e-run.sh` 已設定)。
+
+**Q: `connection refused :18888`**
+
+A: 先 `make e2e-up` 或確認沒有其他 process 佔用 18888。
+
+**Q: 跑完 `make e2e-full` 後 Gateway 還在 :18888?**
+
+A: 舊版用 `go run` 背景跑,`kill $!` 只殺 wrapper、子行程會留著。現已改為編譯 `.cache/e2e-gateway` 再啟動,cleanup 會依 **pid 檔 + port + orphan** 三層關閉。若仍有殘留:
+
+```bash
+make e2e-down
+# 或
+lsof -ti tcp:18888 | xargs kill -9
+```
+
+**Q: 與 `make run-dev`(:8888)衝突?**
+
+A: E2E 固定用 **18888** + **gateway_e2e** DB,互不影響。
+
+**Q: 如何只跑單一測試?**
+
+```bash
+make e2e-up
+GATEWAY_E2E=1 go test -tags=e2e -v -count=1 ./test/e2e/ -run TestMember_GetMe
+make e2e-down
+```
diff --git a/internal/logic/member/verify_helper.go b/internal/logic/member/verify_helper.go
index f462993..a6f39b1 100644
--- a/internal/logic/member/verify_helper.go
+++ b/internal/logic/member/verify_helper.go
@@ -2,6 +2,8 @@ package member
import (
"context"
+ "fmt"
+ "os"
"time"
memberdom "gateway/internal/model/member/domain"
@@ -72,6 +74,10 @@ func startVerification(
}
return nil, sendErr
}
+ if os.Getenv("GATEWAY_E2E") == "1" && sc.Redis != nil && sc.Redis.Zero() != nil {
+ key := fmt.Sprintf("e2e:otp:%s", dto.ChallengeID)
+ _ = sc.Redis.Zero().SetexCtx(ctx, key, plainCode, dto.ExpiresIn)
+ }
return &types.VerificationStartData{
ChallengeID: dto.ChallengeID,
ExpiresIn: dto.ExpiresIn,
diff --git a/internal/model/auth/SDD.md b/internal/model/auth/SDD.md
new file mode 100644
index 0000000..8c175df
--- /dev/null
+++ b/internal/model/auth/SDD.md
@@ -0,0 +1,309 @@
+# Auth Service
+
+# Auth Service — SRS/SDD Document
+
+| Version | Version Date | Editor | Memo |
+|---------|--------------|--------|------|
+| 1.0.0 | 2026/05/21 | Gateway Team | 初版:Gateway Auth 模組 SDD |
+
+---
+
+## 1. Introduction
+
+### 1.1 Purpose
+
+Auth 模組為 TianTing Gateway 的**認證領域層**,提供租戶範圍內的原子化認證原語:邀請碼、註冊稽核、OAuth 暫存 Session、CloudEP JWT 簽發/刷新/登出。完整 HTTP 流程由 `internal/logic/auth/` 編排,並與 ZITADEL(身份)、Member(會員)、Notification(通知)協作。
+
+### 1.2 Scope
+
+**範圍內:**
+
+- 租戶邀請碼(Validate / Consume)
+- 註冊稽核 metadata(條款版本、渠道、行銷 opt-in)
+- OAuth 社交註冊/登入暫存 Session(Redis)
+- CloudEP JWT access / refresh token 生命週期
+- JWT 黑名單與 jti pair 管理
+
+**範圍外(委派其他模組):**
+
+- 使用者身份建立 → ZITADEL
+- 會員 profile、OTP → Member 模組
+- 郵件/簡訊 → Notification 模組
+- RBAC 授權 → Permission 模組 + Casbin middleware
+
+### 1.3 Definitions, Acronyms, and Abbreviation
+
+| 縮寫 | 說明 |
+|------|------|
+| **JWT** | JSON Web Token,CloudEP 自簽 access / refresh token |
+| **JTI** | JWT ID,用於黑名單與 pair 映射 |
+| **ROPG** | Resource Owner Password Grant,密碼登入 |
+| **OIDC** | OpenID Connect,社交登入/SSO |
+| **OTP** | One-Time Password,由 Member 模組管理 |
+| **Tenant** | 租戶,多租戶隔離單位 |
+| **UID** | 租戶內可讀會員識別碼(如 `ACME-10000003`) |
+
+### 1.4 Technologies to be used
+
+| 項目 | 技術 |
+|------|------|
+| Application Language | Go 1.22+ |
+| Framework | go-zero (`rest`, `logx`, `stores/redis`) |
+| Cache / Session | Redis |
+| Database | MongoDB |
+| JWT | `github.com/golang-jwt/jwt/v4`(HS256) |
+| Identity Provider | ZITADEL(`internal/library/zitadel`) |
+| API Codegen | goctl / `.api` 檔 |
+
+### 1.5 Overview
+
+Auth 模組採 Clean Architecture 分層:`domain/` 定義介面與實體,`repository/` 實作 Mongo / Redis 適配器,`usecase/` 提供原子 UseCase。HTTP handler 不直接存取 repository,而是由 logic 層串接 ZITADEL + Member + Auth 原語。
+
+主要流程:
+
+1. **Email 註冊**:Consume invite → ZITADEL 建 user → Member 建 unverified → 發 OTP → confirm 後 Activate + IssuePair
+2. **密碼登入**:ZITADEL ROPG → 查 Member → IssuePair
+3. **社交註冊/登入**:Redis Session 暫存 OAuth state → callback 換 token → Provision / Login
+4. **Token 刷新/登出**:Refresh 黑名單舊 pair 後重簽;Logout 黑名單 access + refresh jti
+
+---
+
+## 2. System Overview
+
+Auth 模組是 Gateway 認證子系統的核心,負責:
+
+- 控制誰可以註冊(邀請碼)
+- 記錄註冊合規稽核
+- 管理短期 OAuth 流程狀態
+- 簽發與驗證 CloudEP JWT(middleware `AuthJWT`)
+
+對外透過 `/api/v1/auth/*` REST API 暴露;JWT 驗證後將 `(tenant_id, uid)` 注入 request context,供 Member / Permission 等下游使用。
+
+---
+
+## 3. System Architecture
+
+### 3.1 System Architecture
+
+```mermaid
+flowchart TB
+ subgraph Client["Client / Frontend"]
+ Web[Web / Mobile App]
+ end
+
+ subgraph Gateway["Gateway"]
+ Handler["handler/auth"]
+ Logic["logic/auth
(orchestration)"]
+ subgraph AuthModule["internal/model/auth"]
+ UC["usecase/"]
+ Repo["repository/"]
+ Domain["domain/"]
+ end
+ MW["middleware/AuthJWT"]
+ end
+
+ subgraph External["External Services"]
+ Zitadel[ZITADEL IdP]
+ Mongo[(MongoDB)]
+ Redis[(Redis)]
+ end
+
+ subgraph Sibling["Sibling Modules"]
+ Member[member]
+ Notif[notification]
+ end
+
+ Web --> Handler
+ Handler --> Logic
+ Logic --> UC
+ Logic --> Zitadel
+ Logic --> Member
+ Logic --> Notif
+ UC --> Repo
+ Repo --> Mongo
+ Repo --> Redis
+ MW --> UC
+```
+
+### 3.2 Decomposition Description
+
+| 套件 | 職責 |
+|------|------|
+| `config/` | JWT TTL、Session TTL 設定 |
+| `domain/entity/` | Mongo 文件模型(InviteCode、RegistrationMetadata) |
+| `domain/enum/` | RegistrationChannel(email / google) |
+| `domain/repository/` | Port 介面 + Redis Session struct |
+| `domain/usecase/` | UseCase 介面 + DTO |
+| `domain/const.go` | BSON 欄位名、Redis key、邀請碼 hash helper |
+| `domain/errors.go` | 領域 sentinel errors |
+| `repository/` | Mongo + Redis 適配器、`EnsureMongoIndexes` |
+| `usecase/` | 實作 + `NewModuleFromParam` factory |
+| `usecase/module.go` | 組裝 Invite / RegistrationMeta / Session UseCase |
+
+**ServiceContext 注入:**
+
+| 欄位 | UseCase | 條件 |
+|------|---------|------|
+| `AuthInvite` | InviteUseCase | Mongo + Redis |
+| `AuthRegistrationMeta` | RegistrationMetaUseCase | Mongo |
+| `AuthRegistrationSession` | RegistrationSessionUseCase | Redis |
+| `AuthLoginSession` | LoginSessionUseCase | Redis |
+| `AuthToken` | TokenUseCase | JWT secret + Redis(獨立於 Module) |
+
+### 3.3 Token
+
+CloudEP JWT 採 HS256,access / refresh 使用不同 secret。
+
+**Claims 結構:**
+
+| Claim | 說明 |
+|-------|------|
+| `tenant_id` | 租戶 ID |
+| `uid` | 會員 UID |
+| `typ` | `access` 或 `refresh` |
+| `auth_gen` | 簽發世代(用於強制登出) |
+| `jti` | Token 唯一 ID |
+| `iat` / `exp` | 簽發/過期時間 |
+
+**預設 TTL:**
+
+| Token | 預設 |
+|-------|------|
+| Access | 900 秒(15 分鐘) |
+| Refresh | 604800 秒(7 天) |
+| Registration Session | 600 秒(10 分鐘) |
+
+**Redis 映射:**
+
+| Key | 用途 |
+|-----|------|
+| `auth:jwt:pair:{jti}` | access ↔ refresh jti 雙向映射 |
+| `auth:jwt:bl:{jti}` | 已登出/已刷新的 jti 黑名單 |
+
+**Refresh 流程:**
+
+```mermaid
+sequenceDiagram
+ participant C as Client
+ participant L as TokenRefreshLogic
+ participant T as TokenUseCase
+ participant R as Redis
+
+ C->>L: POST /auth/token/refresh {refresh_token}
+ L->>T: Refresh(refreshToken)
+ T->>T: Parse + verify typ=refresh
+ T->>R: Blacklist old refresh jti + paired access jti
+ T->>T: IssuePair(new access + refresh)
+ T->>R: SavePair(new jti mapping)
+ T-->>L: TokenPair
+ L-->>C: AuthTokenData
+```
+
+### 3.4 Invite Code
+
+邀請碼以 SHA-256 hash 儲存,明文永不落庫。
+
+| 操作 | 行為 |
+|------|------|
+| **Validate** | 依 `(tenant_id, code_hash)` 查詢,檢查過期與剩餘次數 |
+| **Consume** | Redis SETNX lock(30s)→ 原子 `$inc used_count` |
+
+**消費時機:**
+
+- Email 註冊:在 `/register` 起始即 Consume
+- 社交註冊:Start 僅 Validate;Callback 才 Consume
+
+### 3.5 OAuth Session
+
+| Session 類型 | Redis Key | State 前綴 | 用途 |
+|-------------|-----------|-----------|------|
+| Registration | `auth:register:session:{id}` | `reg:` | 社交註冊暫存 invite / terms |
+| Login | `auth:login:session:{id}` | `login:` | 社交登入暫存 tenant / redirect |
+
+---
+
+## 4. Data Design
+
+### 4.1 Data Dictionary
+
+#### invite_codes(MongoDB)
+
+| Field | Type | Comment | Index |
+|-------|------|---------|-------|
+| _id | ObjectId | PK | PK |
+| tenant_id | String | 租戶 ID | Unique(tenant_id, code_hash) |
+| code_hash | String | SHA-256(normalized code) | Unique(tenant_id, code_hash) |
+| max_uses | Int64 | 最大可用次數 | — |
+| used_count | Int64 | 已使用次數 | — |
+| expires_at | Int64 | 過期時間 ms(0=永不過期) | — |
+| new_users_only | Bool | 僅限新用戶(社交註冊) | — |
+| create_at | Int64 | 建立時間 ms | — |
+| update_at | Int64 | 更新時間 ms | — |
+
+#### registration_metadata(MongoDB)
+
+| Field | Type | Comment | Index |
+|-------|------|---------|-------|
+| _id | ObjectId | PK | PK |
+| tenant_id | String | 租戶 ID | Unique(tenant_id, uid) |
+| uid | String | 會員 UID | Unique(tenant_id, uid) |
+| invite_code_id | String | 使用的邀請碼 ID | — |
+| accept_terms_version | String | 接受的服務條款版本 | — |
+| marketing_opt_in | Bool | 行銷 opt-in | — |
+| registration_channel | String | email / google | — |
+| client_ip | String | 註冊 IP | — |
+| user_agent | String | User-Agent | — |
+| occurred_at | Int64 | 事件時間 ms | — |
+| create_at | Int64 | 建立時間 ms | — |
+
+#### Redis Session / Token Keys
+
+| Key Pattern | Type | TTL | Comment |
+|-------------|------|-----|---------|
+| auth:register:session:{id} | String(JSON) | 600s | 社交註冊 OAuth session |
+| auth:login:session:{id} | String(JSON) | 600s | 社交登入 OAuth session |
+| auth:invite:consume:{tenant}:{hash} | String | 30s | 邀請碼消費鎖 |
+| auth:jwt:pair:{jti} | String | token TTL | access↔refresh 映射 |
+| auth:jwt:bl:{jti} | String | 至自然過期 | JWT 黑名單 |
+
+---
+
+## 5. API Design
+
+**Base Path:** `/api/v1/auth`
+
+### 5.1 Public Endpoints(無 Bearer)
+
+| Method | Path | 說明 |
+|--------|------|------|
+| POST | `/register` | Email 註冊(回傳 challenge_id) |
+| POST | `/register/confirm` | OTP 確認 → 核發 JWT |
+| POST | `/register/resend` | 重發註冊 OTP |
+| POST | `/register/social/start` | 社交註冊 OAuth 起始 |
+| GET | `/register/social/callback` | 社交註冊 OAuth callback |
+| POST | `/login` | 密碼登入 |
+| POST | `/login/social/start` | 社交登入 OAuth 起始 |
+| GET | `/login/social/callback` | 社交登入 OAuth callback |
+| POST | `/token/refresh` | 刷新 token pair |
+| POST | `/token/exchange` | ZITADEL id_token → CloudEP JWT |
+
+### 5.2 Protected Endpoints(需 Bearer JWT)
+
+| Method | Path | 說明 |
+|--------|------|------|
+| POST | `/logout` | 登出(黑名單 jti) |
+
+完整請求/回應 schema 見 `generate/api/auth.api`;成功 envelope `code=102000`。
+
+---
+
+## 6. Resource
+
+| 資源 | 路徑 |
+|------|------|
+| API 定義 | `generate/api/auth.api` |
+| Logic 編排 | `internal/logic/auth/` |
+| JWT Middleware | `internal/middleware/authjwt_*.go` |
+| 模組原始碼 | `internal/model/auth/` |
+| 設定範例 | `etc/gateway.dev.example.yaml` → `Auth` / `JWT` 區塊 |
+| 設計參考 | `docs/identity-member-design.md` |
diff --git a/internal/model/member/SDD.md b/internal/model/member/SDD.md
new file mode 100644
index 0000000..1039ec7
--- /dev/null
+++ b/internal/model/member/SDD.md
@@ -0,0 +1,300 @@
+# Member Service
+
+# Member Service — SRS/SDD Document
+
+| Version | Version Date | Editor | Memo |
+|---------|--------------|--------|------|
+| 1.0.0 | 2026/05/21 | Gateway Team | 初版:Gateway Member 模組 SDD |
+
+---
+
+## 1. Introduction
+
+### 1.1 Purpose
+
+Member 模組為 TianTing Gateway 的**會員核心領域層**,管理多租戶下的 Tenant(租戶)、Member(會員 profile)、Identity(外部身份對映),以及 UID 生成、業務 Email/Phone OTP 驗證、TOTP step-up MFA、重發/每日配額等原子業務原語。
+
+### 1.2 Scope
+
+**範圍內:**
+
+- 租戶建立與 slug 解析
+- 會員生命週期(unverified → active → suspended → deleted)
+- 外部身份 Provision(OIDC / LDAP / SCIM)
+- Profile 讀寫與業務 contact 驗證標記
+- OTP challenge(bcrypt + Redis)
+- TOTP 綁定/驗證/備援碼(AES-GCM 保護 secret)
+- 可讀 UID 序號生成
+
+**範圍外:**
+
+- 密碼驗證、JWT 簽發 → Auth 模組 + ZITADEL
+- 通知寄送 → Notification 模組(logic 層編排)
+- RBAC → Permission 模組
+
+**架構原則:** usecase **不可**呼叫其他 usecase;多步流程由 logic 層編排。
+
+### 1.3 Definitions, Acronyms, and Abbreviation
+
+| 縮寫 | 說明 |
+|------|------|
+| **Tenant** | 租戶,B2B 客戶組織單位 |
+| **Member** | 租戶範圍內的會員 profile |
+| **Identity** | 外部 ID(LDAP/SCIM external_id 或 zitadel_sub)→ UID 對映 |
+| **UID** | 可讀主鍵,格式 `{UIDPrefix}-{Sequence}`(如 `ACME-10000003`) |
+| **OTP** | 一次性數字驗證碼(Email/Phone 業務驗證) |
+| **TOTP** | RFC 6238 時間型 OTP(Google Authenticator) |
+| **Origin** | 會員來源:platform_native / oidc / ldap / scim |
+
+### 1.4 Technologies to be used
+
+| 項目 | 技術 |
+|------|------|
+| Application Language | Go 1.22+ |
+| Framework | go-zero |
+| Cache | Redis(OTP、TOTP enroll、UID seq、verify rate) |
+| Database | MongoDB |
+| Crypto | bcrypt(OTP)、AES-GCM(TOTP secret)、RFC 6238 TOTP |
+| API Codegen | goctl / `.api` 檔 |
+
+### 1.5 Overview
+
+Member 模組提供 7 個 atomic UseCase,由 `NewModuleFromParam` 依 Mongo / Redis / TOTP KEK 條件組裝:
+
+| UseCase | 條件 |
+|---------|------|
+| OTP、VerifyRate | Redis 必填 |
+| Profile、Lifecycle、Tenant、Provisioning | Mongo 設定後 |
+| TOTP | Mongo + `TOTP.SecretKEK` 設定後 |
+
+---
+
+## 2. System Overview
+
+Member 模組是多租戶身份系統的資料核心:
+
+- 每個 Member 必屬一個 Tenant
+- UID 以 `{TenantUIDPrefix}-{Sequence}` 生成,序號從 10,000,000 起跳
+- 平台註冊走 `unverified → active`;OIDC/LDAP/SCIM 首登直接 `active`
+- 業務 Email/Phone 驗證與 TOTP 為 step-up MFA 能力
+
+對外透過 `/api/v1/members/*` REST API 暴露(profile、verification、TOTP)。
+
+---
+
+## 3. System Architecture
+
+### 3.1 System Architecture
+
+```mermaid
+flowchart TB
+ subgraph Client["Client"]
+ App[Frontend / API Client]
+ end
+
+ subgraph Gateway["Gateway"]
+ Handler["handler/member"]
+ Logic["logic/member
(orchestration)"]
+ subgraph MemberModule["internal/model/member"]
+ UC["usecase/ (7 atomic)"]
+ Repo["repository/"]
+ TOTP["totp/ (RFC 6238)"]
+ end
+ end
+
+ subgraph Storage["Storage"]
+ Mongo[(MongoDB)]
+ Redis[(Redis)]
+ end
+
+ subgraph Sibling["Sibling Modules"]
+ Auth[auth]
+ Notif[notification]
+ end
+
+ App --> Handler
+ Handler --> Logic
+ Logic --> UC
+ Logic --> Notif
+ Auth --> UC
+ UC --> Repo
+ UC --> TOTP
+ Repo --> Mongo
+ Repo --> Redis
+```
+
+### 3.2 Decomposition Description
+
+| 套件 | 職責 |
+|------|------|
+| `config/` | OTP / TOTP / Registration 設定 |
+| `domain/entity/` | Member、Tenant、Identity Mongo doc |
+| `domain/enum/` | MemberStatus、MemberOrigin、OTPPurpose、TenantStatus |
+| `domain/repository/` | 7 個 repository 介面 |
+| `domain/usecase/` | 7 個 usecase 介面 + DTO |
+| `domain/redis.go` | Redis key helper(禁止字串拼接) |
+| `repository/` | Mongo + Redis 實作 |
+| `totp/` | RFC 6238 純函式(secret、verify、otpauth URL) |
+| `usecase/` | 實作 + module factory + mapper |
+
+### 3.3 Member Lifecycle
+
+```mermaid
+stateDiagram-v2
+ [*] --> unverified: Lifecycle.CreateUnverified
(platform 註冊)
+ [*] --> active: Provisioning.Ensure*
(OIDC/LDAP/SCIM)
+
+ unverified --> active: Activate (OTP 通過)
+ unverified --> deleted: AbortPending (註冊逾時)
+
+ active --> suspended: Suspend
+ suspended --> active: Reactivate
+ active --> deleted: SoftDelete
+ suspended --> deleted: SoftDelete
+ deleted --> [*]
+```
+
+### 3.4 UID Generation
+
+```mermaid
+sequenceDiagram
+ participant UC as Lifecycle / Provisioning
+ participant Gen as UIDGenerator
+ participant R as Redis
+
+ UC->>Gen: Next(tenant, uidPrefix)
+ Gen->>R: INCR member:seq:{tenant}
+ alt seq == 1 (首次)
+ Gen->>R: INCRBY (UIDSequenceStart - 1)
+ end
+ Gen-->>UC: "{PREFIX}-{seq}"
+```
+
+- `UIDSequenceStart = 10_000_000`
+- `UIDPrefix` 限制 2~4 個大寫字母
+
+### 3.5 OTP / TOTP
+
+**OTP:** bcrypt hash 存 Redis challenge;Verify 成功後立即刪除(一次性)。
+
+**TOTP:** secret 以 AES-GCM cipher 存 Mongo;enroll 階段 staged secret 存 Redis(TTL 600s);VerifyCode 含 replay 保護(timestep 去重)。
+
+---
+
+## 4. Data Design
+
+### 4.1 Data Dictionary
+
+#### tenants(MongoDB)
+
+| Field | Type | Comment | Index |
+|-------|------|---------|-------|
+| _id | ObjectId | PK | PK |
+| tenant_id | String | 租戶 ID | — |
+| slug | String | URL slug | Unique |
+| name | String | 顯示名稱 | — |
+| uid_prefix | String | UID 前綴(2-4 大寫) | Unique |
+| status | String | active / suspended | — |
+| org_id | String | 外部 org ID | — |
+| create_at | Int64 | 建立時間 ms | — |
+| update_at | Int64 | 更新時間 ms | — |
+
+#### members(MongoDB)
+
+| Field | Type | Comment | Index |
+|-------|------|---------|-------|
+| _id | ObjectId | PK | PK |
+| tenant_id | String | 租戶 ID | Unique(tenant_id, uid) |
+| uid | String | 可讀 UID | Unique(tenant_id, uid) |
+| zitadel_user_id | String | ZITADEL sub | Unique(tenant_id, zitadel_user_id) sparse |
+| zitadel_email | String | IdP email | — |
+| display_name | String | 顯示名稱 | — |
+| avatar | String | 頭像 URL | — |
+| phone | String | 聯絡電話 | — |
+| language | String | 語系 | — |
+| currency | String | 幣別 | — |
+| member_status | String | unverified/active/suspended/deleted | — |
+| origin | String | platform_native/oidc/ldap/scim | — |
+| business_email | String | 業務 email | — |
+| business_email_verified | Bool | 業務 email 已驗證 | — |
+| business_phone | String | 業務 phone | — |
+| business_phone_verified | Bool | 業務 phone 已驗證 | — |
+| totp_enrolled | Bool | TOTP 已綁定 | — |
+| totp_secret_cipher | Binary | AES-GCM 加密 secret | — |
+| totp_backup_codes_hash | Array | bcrypt 備援碼 hash | — |
+| suspend_reason | String | 停權原因 | — |
+| create_at | Int64 | 建立時間 ms | — |
+| update_at | Int64 | 更新時間 ms | — |
+| deleted_at | Int64 | 軟刪時間 ms | — |
+
+#### identities(MongoDB)
+
+| Field | Type | Comment | Index |
+|-------|------|---------|-------|
+| _id | ObjectId | PK | PK |
+| tenant_id | String | 租戶 ID | Unique(tenant_id, external_id) |
+| external_id | String | LDAP/SCIM 外部 ID | Unique(tenant_id, external_id) |
+| zitadel_user_id | String | ZITADEL sub | Unique(tenant_id, zitadel_user_id) |
+| uid | String | 對應 Member UID | — |
+| create_at | Int64 | 建立時間 ms | — |
+
+#### Redis Keys
+
+| Key Pattern | 用途 | TTL |
+|-------------|------|-----|
+| member:otp:challenge:{id} | OTP challenge(bcrypt hash) | 300s |
+| member:otp:challenge:{id}:attempts | OTP 錯誤次數 | 同 challenge |
+| member:verify:rate:{tenant}:{uid}:{kind} | 重發冷卻 | 60s |
+| member:verify:daily:{tenant}:{uid}:{kind} | 每日上限計數 | 24h |
+| member:totp:enroll:{tenant}:{uid} | TOTP 綁定 staged secret | 600s |
+| member:totp:used:{tenant}:{uid}:{step} | TOTP 重放保護 | 90s |
+| member:seq:{tenant} | UID 序號 INCR | 永久 |
+
+---
+
+## 5. API Design
+
+**Base Path:** `/api/v1/members`
+
+### 5.1 Profile
+
+| Method | Path | 說明 |
+|--------|------|------|
+| GET | `/me` | 取得當前會員 profile |
+| PATCH | `/me` | 更新 display_name / avatar / language 等 |
+
+### 5.2 Verification(OTP)
+
+| Method | Path | 說明 |
+|--------|------|------|
+| POST | `/me/verifications/email/start` | 發起業務 email OTP |
+| POST | `/me/verifications/email/confirm` | 確認 email OTP |
+| POST | `/me/verifications/phone/start` | 發起業務 phone OTP |
+| POST | `/me/verifications/phone/confirm` | 確認 phone OTP |
+
+### 5.3 TOTP
+
+| Method | Path | 說明 |
+|--------|------|------|
+| GET | `/me/totp/status` | TOTP 綁定狀態 |
+| POST | `/me/totp/enroll` | 開始綁定(回 otpauth_url) |
+| POST | `/me/totp/enroll/confirm` | 確認綁定(回 backup codes) |
+| POST | `/me/totp/verify` | Step-up 驗碼 |
+| POST | `/me/totp/disable` | 解除綁定 |
+| POST | `/me/totp/backup-codes/regenerate` | 重產備援碼 |
+
+完整 schema 見 `generate/api/member.api`。
+
+---
+
+## 6. Resource
+
+| 資源 | 路徑 |
+|------|------|
+| API 定義 | `generate/api/member.api` |
+| Logic 編排 | `internal/logic/member/` |
+| 模組原始碼 | `internal/model/member/` |
+| 開發 README | `internal/model/member/README.md` |
+| 設定範例 | `etc/gateway.dev.example.yaml` → `Member` 區塊 |
+| 設計參考 | `docs/identity-member-design.md`、`docs/model.md` §6.1 |
+| Seed CLI | `cmd/member-seed` |
diff --git a/internal/model/notification/SDD.md b/internal/model/notification/SDD.md
new file mode 100644
index 0000000..5947b15
--- /dev/null
+++ b/internal/model/notification/SDD.md
@@ -0,0 +1,297 @@
+# Notification Service
+
+# Notification Service — SRS/SDD Document
+
+| Version | Version Date | Editor | Memo |
+|---------|--------------|--------|------|
+| 1.0.0 | 2026/05/21 | Gateway Team | 初版:Gateway Notification 模組 SDD |
+
+---
+
+## 1. Introduction
+
+### 1.1 Purpose
+
+Notification 模組為 TianTing Gateway 的**統一對外通知入口**,支援 Email 與 SMS 的同步寄送、異步佇列、冪等、租戶配額、指數退避重試與 DLQ(Dead Letter Queue)管理。
+
+### 1.2 Scope
+
+**範圍內:**
+
+- 同步 `Send`:渲染模板後立即送達
+- 異步 `Enqueue`:Mongo 寫入 pending + Redis ZSET 排程 + RetryWorker 背景投遞
+- 冪等(Redis cache + Mongo unique index)
+- 租戶每日 Email/SMS 配額
+- 模板渲染(embed HTML / SMS / Subject,多語系)
+- Provider chain(SMTP、SES、Mitake、Mock)
+- Admin DLQ 查詢與手動重試
+
+**範圍外:**
+
+- Push / Webhook(enum 已定義,尚未實作)
+- 專用 HTTP API(目前僅內部 logic 呼叫;擴充路徑見 README)
+
+### 1.3 Definitions, Acronyms, and Abbreviation
+
+| 縮寫 | 說明 |
+|------|------|
+| **NotifyKind** | 通知類型(verify_email、tenant_welcome 等) |
+| **Channel** | 通道:email / sms / push / webhook |
+| **DLQ** | Dead Letter Queue,超過 MaxRetry 的失敗通知 |
+| **IdempotencyKey** | 冪等鍵,同 tenant+kind+key 不重複寄送 |
+| **TargetHash** | SHA-256(target),Mongo 不存明文 email/phone |
+| **RetryJob** | Redis ZSET 成員,含 target(僅 Redis,不落 Mongo) |
+
+### 1.4 Technologies to be used
+
+| 項目 | 技術 |
+|------|------|
+| Application Language | Go 1.22+ |
+| Framework | go-zero |
+| Database | MongoDB(notifications、notification_dlq) |
+| Cache / Queue | Redis(冪等、配額、retry ZSET) |
+| Templates | go:embed + Go text/html template |
+| Email Providers | SMTP、AWS SES、Mock |
+| SMS Providers | Mitake(三竹)、Mock |
+
+### 1.5 Overview
+
+Notification 為 library-style domain package,嵌入 Gateway process 內執行:
+
+1. **Send**:Caller → 冪等檢查 → 配額 → Insert pending → Render → Provider chain → Update sent/failed
+2. **Enqueue**:同上至 Insert → ZADD Redis → 回傳 pending;RetryWorker 背景 ClaimDue → 投遞
+3. **DLQ**:attempts ≥ MaxRetry → status=dropped + 寫入 notification_dlq
+
+---
+
+## 2. System Overview
+
+Notification 模組解耦業務邏輯與實際寄送:
+
+- Auth 註冊 OTP、Member 業務驗證等 logic 層呼叫 `Notifier.Send`
+- 歡迎信等可容忍延遲的通知可走 `Enqueue` + Worker
+- 隱私設計:Mongo / DLQ 只存 `target_hash`,明文 target 僅在 Redis RetryJob 生命週期內存在
+
+Worker 由 `ServiceContext.StartWorkers` 啟動,與 Gateway 同 process 運行。
+
+---
+
+## 3. System Architecture
+
+### 3.1 System Architecture
+
+```mermaid
+flowchart TB
+ subgraph Callers["Internal Callers"]
+ AuthLogic[logic/auth]
+ MemberLogic[logic/member]
+ end
+
+ subgraph NotificationModule["internal/model/notification"]
+ Notifier[NotifierUseCase]
+ Admin[AdminNotifierUseCase]
+ Worker[RetryWorker]
+ Factory[Provider Factory]
+ Renderer[Template Renderer]
+ end
+
+ subgraph Providers["Providers"]
+ EmailChain[email.Chain
SMTP / SES / Mock]
+ SMSChain[sms.Chain
Mitake / Mock]
+ end
+
+ subgraph Storage["Storage"]
+ Mongo[(MongoDB)]
+ Redis[(Redis)]
+ end
+
+ AuthLogic --> Notifier
+ MemberLogic --> Notifier
+ Notifier --> Renderer
+ Notifier --> Factory
+ Notifier --> Mongo
+ Notifier --> Redis
+ Factory --> EmailChain
+ Factory --> SMSChain
+ Worker --> Redis
+ Worker --> Mongo
+ Worker --> EmailChain
+ Worker --> SMSChain
+ Admin --> Mongo
+ Admin --> Redis
+```
+
+### 3.2 Decomposition Description
+
+| 套件 | 職責 |
+|------|------|
+| `config/` | Async、Rate、Email、SMS 設定 |
+| `domain/entity/` | Notification、NotificationDLQ |
+| `domain/enum/` | Channel、NotifyKind、NotifyStatus、Severity |
+| `domain/repository/` | Notification、DLQ、Store、RetryQueue 介面 |
+| `domain/usecase/` | Notifier、Admin 介面 + DTO |
+| `domain/template/` | 模板 Spec、Renderer、Registry 介面 |
+| `repository/` | Mongo + Redis 實作 |
+| `usecase/` | Notifier、Admin、RetryWorker、factory、module |
+| `provider/email/` | SMTP、SES、Mock sender + chain |
+| `provider/sms/` | Mitake、Mock sender + chain |
+| `template/` | embed 模板 + render |
+
+### 3.3 Send(同步)
+
+```mermaid
+sequenceDiagram
+ participant C as Caller
+ participant N as NotifierUseCase
+ participant R as Redis
+ participant M as Mongo
+ participant P as Provider
+
+ C->>N: Send(SendRequest)
+ N->>R: Get idempotency
+ alt cache hit
+ N-->>C: cached DTO
+ else miss
+ N->>M: FindByIdempotency / Insert pending
+ N->>R: Incr quota
+ N->>N: Render template
+ N->>P: Email/SMS chain.Send
+ N->>M: UpdateDelivery (sent/failed)
+ N->>R: Set idempotency cache
+ N-->>C: NotificationDTO
+ end
+```
+
+### 3.4 Enqueue + RetryWorker(異步)
+
+```mermaid
+sequenceDiagram
+ participant C as Caller
+ participant N as NotifierUseCase
+ participant M as Mongo
+ participant R as Redis
+ participant W as RetryWorker
+ participant P as Provider
+
+ C->>N: Enqueue(SendRequest)
+ N->>M: Insert (pending)
+ N->>R: ZADD RetryJob (score=now)
+ N-->>C: pending DTO
+
+ loop every 1s
+ W->>R: ClaimDue (ZREM)
+ W->>M: FindByID
+ W->>P: Render + deliver
+ alt success
+ W->>M: status=sent
+ else attempts < MaxRetry
+ W->>M: status=retrying
+ W->>R: ZADD (score=now+backoff)
+ else
+ W->>M: status=dropped
+ W->>M: Insert DLQ
+ end
+ end
+```
+
+**退避序列(預設):** `[1, 5, 30, 300, 1800]` 秒
+
+---
+
+## 4. Data Design
+
+### 4.1 Data Dictionary
+
+#### notifications(MongoDB)
+
+| Field | Type | Comment | Index |
+|-------|------|---------|-------|
+| _id | ObjectId | PK | PK |
+| tenant_id | String | 租戶 ID | Unique(tenant_id, kind, idempotency_key) |
+| uid | String | 會員 UID | (tenant_id, uid, occurred_at desc) |
+| channel | String | email / sms | — |
+| kind | String | NotifyKind | Unique(tenant_id, kind, idempotency_key) |
+| target_hash | String | SHA-256(target) | — |
+| template_key | String | 模板 key | — |
+| locale | String | 語系 | — |
+| body | String | 渲染後內容(OTP 類可省略) | — |
+| provider | String | 實際使用的 provider | — |
+| provider_message_id | String | 外部 message ID | — |
+| status | String | pending/sent/failed/retrying/dropped | (status, attempts, occurred_at) |
+| attempts | Int | 投遞嘗試次數 | — |
+| last_error | String | 最後錯誤 | — |
+| idempotency_key | String | 冪等鍵 | Unique(tenant_id, kind, idempotency_key) |
+| severity | String | info/warn/critical | — |
+| occurred_at | Int64 | 事件時間 ms | — |
+| delivered_at | Int64 | 送達時間 ms | — |
+| create_at | Int64 | 建立時間 ms | — |
+| update_at | Int64 | 更新時間 ms | — |
+
+#### notification_dlq(MongoDB)
+
+| Field | Type | Comment | Index |
+|-------|------|---------|-------|
+| _id | ObjectId | PK | PK |
+| notification_id | String | 原 notification ID | — |
+| tenant_id | String | 租戶 ID | — |
+| uid | String | 會員 UID | — |
+| channel | String | 通道 | — |
+| kind | String | 通知類型 | — |
+| target_hash | String | target hash | — |
+| last_error | String | 最後錯誤 | — |
+| attempts | Int | 總嘗試次數 | — |
+| payload | Object | 重試 metadata(locale、data 等,不含 target) | — |
+| occurred_at | Int64 | 事件時間 ms | — |
+| create_at | Int64 | 建立時間 ms | — |
+
+#### Redis Keys
+
+| Key Pattern | 用途 | TTL |
+|-------------|------|-----|
+| notif:idem:{tenant}:{kind}:{key} | 冪等 cache | 24h |
+| notif:quota:{tenant}:{channel}:{yyyyMMdd} | 每日配額計數 | 25h |
+| notif:retry:zset(可配置) | RetryJob ZSET(score=run_at_ms) | — |
+
+#### NotifyKind 一覽
+
+| Kind | 通道 | 說明 |
+|------|------|------|
+| verify_email | email | 業務 email OTP |
+| verify_phone | sms | 業務 phone OTP |
+| verify_registration_email | email | 註冊 email OTP |
+| step_up_email | email | Email step-up |
+| step_up_phone | sms | Phone step-up |
+| account_suspended | email | 帳號停權通知 |
+| tenant_welcome | email | 租戶歡迎信 |
+
+---
+
+## 5. API Design
+
+**現況:** 尚無專用 HTTP API。Notification 由內部 logic 呼叫:
+
+| 呼叫方 | 方法 | 用途 |
+|--------|------|------|
+| `logic/auth/register_logic.go` | `Notifier.Send` | 註冊 email OTP |
+| `logic/member/verify_helper.go` | `Notifier.Send` | 業務 email/phone OTP |
+| `cmd/notify-test` | Send / Enqueue / Admin | 本機測試 CLI |
+
+**擴充 HTTP API 路徑:**
+
+1. 在 `generate/api/` 定義路由
+2. `make gen-api`
+3. `internal/logic` 映射 types ↔ DTO
+
+---
+
+## 6. Resource
+
+| 資源 | 路徑 |
+|------|------|
+| 模組原始碼 | `internal/model/notification/` |
+| 開發 README | `internal/model/notification/README.md` |
+| 測試指南 | `docs/notification-testing.md` |
+| 測試 CLI | `cmd/notify-test`(`make notify-test`) |
+| Worker | `internal/worker/notification_retry/` |
+| 設定範例 | `etc/gateway.dev.example.yaml` → `Notification` 區塊 |
+| 設計參考 | `docs/identity-member-design.md` §11 |
diff --git a/internal/model/permission/SDD.md b/internal/model/permission/SDD.md
new file mode 100644
index 0000000..a12680b
--- /dev/null
+++ b/internal/model/permission/SDD.md
@@ -0,0 +1,327 @@
+# Permission Service
+
+# Permission Service — SRS/SDD Document
+
+| Version | Version Date | Editor | Memo |
+|---------|--------------|--------|------|
+| 1.0.0 | 2026/05/21 | Gateway Team | 初版:Gateway Permission 模組 SDD(對齊 frontend Permission Service SDD 格式) |
+| 1.1.0 | — | — | 新增 category 樹狀 Permission Catalog |
+| 1.2.0 | — | — | 多租戶 B2B RBAC + Casbin + RoleMapping |
+
+---
+
+## 1. Introduction
+
+### 1.1 Purpose
+
+Permission 模組提供 Gateway **多租戶 B2B 自定義 RBAC**:平台級 Permission Catalog + 租戶級 Role / RolePermission / UserRole / RoleMapping,搭配 Casbin enforcer 進行 HTTP path/method 授權,並支援外部 IdP(ZITADEL / LDAP / SCIM)角色映射同步。
+
+### 1.2 Scope
+
+**範圍內(Multiple tenants):**
+
+- 平台 Permission Catalog(樹狀,dot notation)
+- 租戶 Role CRUD(含 system role 防呆)
+- Role ↔ Permission 多對多(含 parent closure)
+- User ↔ Role 多對多(source: manual / zitadel / ldap / scim)
+- 外部 group → 內部 Role.Key 映射(RoleMapping)
+- Casbin policy 物化(Redis Set)+ 多 pod Pub/Sub reload
+- GET /permissions/me 前端選單渲染
+
+**範圍外:**
+
+- JWT 簽發 → Auth 模組
+- 會員資料 → Member 模組
+- Platform admin bypass → middleware 預檢(保留 audit)
+
+### 1.3 Definitions, Acronyms, and Abbreviation
+
+| 縮寫 | 說明 |
+|------|------|
+| **RBAC** | Role-Based Access Control |
+| **Permission** | 平台級權限節點(可為分類或 leaf API 權限) |
+| **Role** | 租戶內角色,`key` 唯一且不可改 |
+| **RolePermission** | Role 勾選的 Permission 集合 |
+| **UserRole** | 使用者被指派的角色 |
+| **RoleMapping** | 外部 IdP group/role → 內部 Role.Key |
+| **Casbin** | 授權引擎,policy 存 Redis |
+| **Leaf Permission** | 有 http_path + http_methods 的可 enforcement 節點 |
+
+### 1.4 Technologies to be used
+
+| 項目 | 技術 |
+|------|------|
+| Application Language | Go 1.22+ |
+| Framework | go-zero |
+| Cache / Policy Store | Redis(Casbin rules Set + Pub/Sub) |
+| Database | MongoDB |
+| Authorization Engine | Casbin(`keyMatch2` + `regexMatch`) |
+| API Codegen | goctl / `.api` 檔 |
+
+### 1.5 Overview
+
+```mermaid
+flowchart LR
+ subgraph Platform["平台層"]
+ Catalog[Permission Catalog]
+ end
+ subgraph Tenant["租戶層"]
+ Role[Role]
+ RP[RolePermission]
+ UR[UserRole]
+ RM[RoleMapping]
+ end
+ Catalog --> RP --> Role
+ Role --> UR
+ Role --> RM
+ Tenant --> Casbin[(Casbin Enforcer
Redis adapter)]
+ Casbin --> MW[CasbinRBAC Middleware]
+```
+
+- Permission **平台 seed 全局**,租戶不可新增,只能勾選
+- Role / RolePermission / UserRole **租戶獨立**
+- Role.Key 一旦建立 **不可改**(外部 IdP 對應用)
+- 多 pod 同步:**Redis Pub/Sub 即時 + cron 兜底**
+
+---
+
+## 2. System Overview
+
+Permission 模組是 Gateway 授權子系統:
+
+1. 平台維護 Permission Catalog(`cmd/permission-seed`)
+2. 租戶管理員建立 Role、勾選 Permission
+3. 指派 UserRole(手動或 SyncFromX 同步)
+4. RBACUseCase 物化 Casbin policy → middleware 檢查 `(tenant, role.key, path, method)`
+
+前端透過 `GET /permissions/me` 取得當前使用者的 role keys 與 permission map(含可選 tree)。
+
+---
+
+## 3. System Architecture
+
+### 3.1 System Architecture
+
+```mermaid
+flowchart TD
+ Logic[logic/permission] --> SVC[svc.ServiceContext]
+ SVC --> AuthQ[AuthorizationQueryUseCase]
+ SVC --> Perm[PermissionUseCase]
+ SVC --> Role[RoleUseCase]
+ SVC --> RolePerm[RolePermissionUseCase]
+ SVC --> UserRole[UserRoleUseCase]
+ SVC --> Mapping[RoleMappingUseCase]
+ SVC --> RBAC[RBACUseCase]
+
+ RBAC --> Adapter[Casbin Redis Adapter]
+ Adapter --> Redis[(Redis)]
+ RBAC --> Pub[Redis Pub/Sub casbin:reload]
+
+ Perm --> PermR[(permissions)]
+ Role --> RoleR[(roles)]
+ RolePerm --> RPR[(role_permissions)]
+ UserRole --> URR[(user_roles)]
+ Mapping --> RMR[(role_mappings)]
+```
+
+### 3.2 Decomposition Description
+
+| 套件 | 職責 |
+|------|------|
+| `config/` | CasbinConfig、CacheConfig、ReloadConfig |
+| `domain/entity/` | Permission、Role、RolePermission、UserRole、RoleMapping |
+| `domain/enum/` | Status、PermissionType、RoleSource |
+| `domain/repository/` | 5 個 Mongo repo 介面 + Casbin adapter port |
+| `domain/usecase/` | 7 個 usecase 介面 + DTO |
+| `repository/` | Mongo + Redis Casbin adapter |
+| `usecase/` | 7 個 atomic usecase 實作 + permission_tree |
+| `seed/` | catalog.json + Apply CLI |
+
+### 3.3 RBAC Model
+
+Casbin 模型(`etc/rbac.conf`):
+
+```ini
+[request_definition]
+r = tenant, role, path, method
+
+[policy_definition]
+p = tenant, role, path, methods, name
+
+[policy_effect]
+e = some(where (p.eft == allow))
+
+[matchers]
+m = r.tenant == p.tenant && r.role == p.role && keyMatch2(r.path, p.path) && regexMatch(r.method, p.methods)
+```
+
+**授權檢查(any-allow):** 使用者所有 open role 逐一 EnforceEx,任一 allow 即通過。
+
+```mermaid
+sequenceDiagram
+ participant MW as CasbinRBAC Middleware
+ participant RBAC as RBACUseCase
+ participant URR as UserRoleRepository
+ participant Enf as casbin.Enforcer
+
+ MW->>RBAC: Check{tenant, uid, path, method}
+ RBAC->>URR: ListByUser
+ loop each open role
+ RBAC->>Enf: EnforceEx(tenant, role.key, path, method)
+ alt allow
+ RBAC-->>MW: Allow
+ end
+ end
+ RBAC-->>MW: Deny → 403
+```
+
+### 3.4 Permission Tree
+
+```
+member.info.management ← 分類(無 HTTP)
+├── member.basic.info
+│ ├── member.info.select GET /api/v1/members/me
+│ └── member.info.update PATCH /api/v1/members/me
+└── member.admin.list GET /api/v1/members
+
+permission.role.management
+├── permission.role.read GET /api/v1/permissions/roles
+└── permission.role.write POST/PUT/DELETE /api/v1/permissions/roles*
+```
+
+分類節點(無 `http_path`)不寫入 Casbin policy;Replace RolePermission 時自動補齊 parent closure。
+
+### 3.5 Policy Reload(多 Pod)
+
+```mermaid
+sequenceDiagram
+ participant PodA as Pod A
+ participant Redis
+ participant PodB as Pod B
+
+ PodA->>PodA: RolePermission.Replace + LoadPolicy
+ PodA->>Redis: PUBLISH casbin:reload {tenant_id}
+ Redis-->>PodB: message
+ PodB->>PodB: LoadPolicy(tenant)
+```
+
+---
+
+## 4. Data Design
+
+### 4.1 Data Dictionary
+
+#### permissions(MongoDB — 平台全局)
+
+| Field | Type | Comment | Index |
+|-------|------|---------|-------|
+| _id | ObjectId | PK | PK |
+| parent | String | 父節點 ObjectId hex | parent |
+| name | String | dot notation 唯一名稱 | Unique |
+| http_methods | String | GET 或 GET\|POST\|PATCH | — |
+| http_path | String | keyMatch2 path pattern | — |
+| status | String | open / close | status |
+| type | String | backend_user / frontend_user | type |
+| create_at | Int64 | 建立時間 ms | — |
+| update_at | Int64 | 更新時間 ms | — |
+
+#### roles(MongoDB — 租戶範圍)
+
+| Field | Type | Comment | Index |
+|-------|------|---------|-------|
+| _id | ObjectId | PK | PK |
+| tenant_id | String | 租戶 ID | Unique(tenant_id, key) |
+| key | String | 角色 key(不可改) | Unique(tenant_id, key) |
+| display_name | String | 顯示名稱 | — |
+| creator_uid | String | 建立者 UID | — |
+| status | String | open / close | (tenant_id, is_system) |
+| is_system | Bool | 平台預設角色 | (tenant_id, is_system) |
+| create_at | Int64 | 建立時間 ms | — |
+| update_at | Int64 | 更新時間 ms | — |
+
+#### role_permissions(MongoDB)
+
+| Field | Type | Comment | Index |
+|-------|------|---------|-------|
+| _id | ObjectId | PK | PK |
+| tenant_id | String | 租戶 ID | Unique(tenant_id, role_id, permission_id) |
+| role_id | String | Role ObjectId hex | (tenant_id, role_id) |
+| permission_id | String | Permission ObjectId hex | (tenant_id, permission_id) |
+| create_at | Int64 | 建立時間 ms | — |
+
+#### user_roles(MongoDB)
+
+| Field | Type | Comment | Index |
+|-------|------|---------|-------|
+| _id | ObjectId | PK | PK |
+| tenant_id | String | 租戶 ID | Unique(tenant_id, uid, role_id) |
+| uid | String | 會員 UID | (tenant_id, uid, source) |
+| role_id | String | Role ObjectId hex | (tenant_id, role_id) |
+| source | String | manual / zitadel / ldap / scim | (tenant_id, uid, source) |
+| create_at | Int64 | 建立時間 ms | — |
+
+#### role_mappings(MongoDB)
+
+| Field | Type | Comment | Index |
+|-------|------|---------|-------|
+| _id | ObjectId | PK | PK |
+| tenant_id | String | 租戶 ID | Unique(tenant_id, external_source, external_key) |
+| external_source | String | zitadel / ldap / scim | Unique(tenant_id, external_source, external_key) |
+| external_key | String | 外部 group/role key | Unique(tenant_id, external_source, external_key) |
+| internal_role_id | String | 內部 Role ID | (tenant_id, internal_role_id) |
+| create_at | Int64 | 建立時間 ms | — |
+| update_at | Int64 | 更新時間 ms | — |
+
+#### Redis Keys
+
+| Key Pattern | 內容 | TTL |
+|-------------|------|-----|
+| permission:casbin:rules:{tenant_id} | Set of JSON Casbin rules | 永久 |
+| perm:user_roles:{tenant_id}:{uid} | role keys 快取(預留) | 300s |
+| perm:role_perms:{tenant_id}:{role_id} | permission names 快取(預留) | 300s |
+| (channel) casbin:reload | Pub/Sub reload payload | — |
+
+**Casbin rule 格式:** `[tenant, role.key, http_path, http_methods, perm.name]`
+
+---
+
+## 5. API Design
+
+**Base Path:** `/api/v1/permissions`
+
+| Method | Path | 說明 |
+|--------|------|------|
+| GET | `/catalog` | 全局 Permission Catalog(tree/list) |
+| GET | `/me` | 當前 user 的 roles + permissions |
+| GET | `/roles` | 租戶角色清單 |
+| POST | `/roles` | 建立角色 |
+| PATCH | `/roles/:id` | 更新 display_name / status |
+| DELETE | `/roles/:id` | 刪除角色 |
+| GET | `/roles/:id/permissions` | 角色 permission 集合 |
+| PUT | `/roles/:id/permissions` | 全量取代 + parent closure + reload |
+| GET | `/users/:uid/roles` | 使用者 role 列表 |
+| POST | `/users/:uid/roles` | 指派角色 |
+| DELETE | `/users/:uid/roles/:role_id` | 撤銷角色 |
+| GET | `/role-mappings` | 外部映射列表 |
+| PUT | `/role-mappings` | Upsert 外部映射 |
+| DELETE | `/role-mappings` | 刪除外部映射 |
+| POST | `/policy/reload` | 強制重載 policy(單租戶或 `*`) |
+
+完整 schema 見 `generate/api/permission.api`。
+
+---
+
+## 6. Resource
+
+| 資源 | 路徑 |
+|------|------|
+| API 定義 | `generate/api/permission.api` |
+| Logic | `internal/logic/permission/` |
+| Middleware | `internal/middleware/casbin_rbac.go` |
+| 模組原始碼 | `internal/model/permission/` |
+| 開發 README | `internal/model/permission/README.md` |
+| Casbin 模型 | `etc/rbac.conf` |
+| Seed 資料 | `internal/model/permission/seed/catalog.json` |
+| Seed CLI | `cmd/permission-seed` |
+| 設定範例 | `etc/gateway.dev.example.yaml` → `Permission` 區塊 |
+| 設計參考 | `docs/identity-member-design.md` §6 / §7.3 / §13 |
diff --git a/internal/model/permission/seed/catalog.go b/internal/model/permission/seed/catalog.go
index 6a187d6..46116ff 100644
--- a/internal/model/permission/seed/catalog.go
+++ b/internal/model/permission/seed/catalog.go
@@ -7,6 +7,7 @@ import (
_ "embed"
"encoding/json"
"fmt"
+ "sort"
"time"
"gateway/internal/model/permission/domain/entity"
@@ -129,12 +130,12 @@ func Apply(
return report, nil
}
- idByName, err := loadCatalogIDIndex(ctx, perms)
+ allPerms, permByName, err := loadCatalogIndex(ctx, perms)
if err != nil {
return nil, err
}
for _, tenantID := range opts.TenantIDs {
- if err := seedTenantRoles(ctx, roles, rolePerms, tenantID, idByName, report); err != nil {
+ if err := seedTenantRoles(ctx, roles, rolePerms, tenantID, allPerms, permByName, report); err != nil {
return nil, err
}
}
@@ -225,23 +226,42 @@ func loadCatalogIDIndex(
ctx context.Context,
perms domrepo.PermissionRepository,
) (map[string]string, error) {
- all, err := perms.GetAll(ctx, nil)
+ all, byName, err := loadCatalogIndex(ctx, perms)
if err != nil {
return nil, err
}
idByName := make(map[string]string, len(all))
- for _, p := range all {
- idByName[p.Name] = p.ID.Hex()
+ for name, perm := range byName {
+ idByName[name] = perm.ID.Hex()
}
return idByName, nil
}
+func loadCatalogIndex(
+ ctx context.Context,
+ perms domrepo.PermissionRepository,
+) ([]*entity.Permission, map[string]*entity.Permission, error) {
+ all, err := perms.GetAll(ctx, nil)
+ if err != nil {
+ return nil, nil, err
+ }
+ sort.SliceStable(all, func(i, j int) bool {
+ return all[i].Name < all[j].Name
+ })
+ byName := make(map[string]*entity.Permission, len(all))
+ for _, p := range all {
+ byName[p.Name] = p
+ }
+ return all, byName, nil
+}
+
func seedTenantRoles(
ctx context.Context,
roles domrepo.RoleRepository,
rolePerms domrepo.RolePermissionRepository,
tenantID string,
- idByName map[string]string,
+ allPerms []*entity.Permission,
+ permByName map[string]*entity.Permission,
report *Report,
) error {
for _, def := range DefaultSystemRoles {
@@ -260,13 +280,9 @@ func seedTenantRoles(
}
report.RolesUpserted++
}
- permissionIDs := make([]string, 0, len(def.PermissionNames))
- for _, name := range def.PermissionNames {
- id, ok := idByName[name]
- if !ok {
- return fmt.Errorf("permission seed: catalog missing %q for role %s", name, def.Key)
- }
- permissionIDs = append(permissionIDs, id)
+ permissionIDs, err := resolveRolePermissionIDs(def, allPerms, permByName)
+ if err != nil {
+ return err
}
if err := rolePerms.SetForRole(ctx, tenantID, role.ID.Hex(), permissionIDs); err != nil {
return fmt.Errorf("permission seed: tenant=%s set role perms %s: %w", tenantID, def.Key, err)
@@ -275,3 +291,63 @@ func seedTenantRoles(
}
return nil
}
+
+func resolveRolePermissionIDs(
+ def SystemRoleDefinition,
+ allPerms []*entity.Permission,
+ permByName map[string]*entity.Permission,
+) ([]string, error) {
+ childrenByParent := make(map[string][]*entity.Permission, len(allPerms))
+ parentByID := make(map[string]string, len(allPerms))
+ for _, perm := range allPerms {
+ id := perm.ID.Hex()
+ parentByID[id] = perm.Parent
+ childrenByParent[perm.Parent] = append(childrenByParent[perm.Parent], perm)
+ }
+
+ seen := make(map[string]struct{}, len(def.PermissionNames)*4)
+ for _, name := range def.PermissionNames {
+ perm, ok := permByName[name]
+ if !ok {
+ return nil, fmt.Errorf("permission seed: catalog missing %q for role %s", name, def.Key)
+ }
+ markWithParents(perm.ID.Hex(), parentByID, seen)
+ if !perm.IsLeaf() {
+ markDescendantLeaves(perm.ID.Hex(), childrenByParent, parentByID, seen)
+ }
+ }
+
+ out := make([]string, 0, len(seen))
+ for _, perm := range allPerms {
+ id := perm.ID.Hex()
+ if _, ok := seen[id]; ok {
+ out = append(out, id)
+ }
+ }
+ return out, nil
+}
+
+func markDescendantLeaves(
+ parentID string,
+ childrenByParent map[string][]*entity.Permission,
+ parentByID map[string]string,
+ seen map[string]struct{},
+) {
+ for _, child := range childrenByParent[parentID] {
+ if child.IsLeaf() {
+ markWithParents(child.ID.Hex(), parentByID, seen)
+ continue
+ }
+ markDescendantLeaves(child.ID.Hex(), childrenByParent, parentByID, seen)
+ }
+}
+
+func markWithParents(id string, parentByID map[string]string, seen map[string]struct{}) {
+ for id != "" {
+ if _, ok := seen[id]; ok {
+ return
+ }
+ seen[id] = struct{}{}
+ id = parentByID[id]
+ }
+}
diff --git a/scripts/e2e-down.sh b/scripts/e2e-down.sh
new file mode 100755
index 0000000..02e02c8
--- /dev/null
+++ b/scripts/e2e-down.sh
@@ -0,0 +1,21 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+# shellcheck source=scripts/e2e-lib.sh
+source "${ROOT}/scripts/e2e-lib.sh"
+cd "$ROOT"
+
+GATEWAY_PORT="${GATEWAY_PORT:-18888}"
+PID_FILE="${PID_FILE:-${ROOT}/test/e2e/fixtures/gateway.pid}"
+
+e2e_stop_gateway "${GATEWAY_PORT}" "${PID_FILE}"
+
+if command -v lsof >/dev/null 2>&1 && lsof -ti tcp:"${GATEWAY_PORT}" >/dev/null 2>&1; then
+ echo "e2e-down: warning — port ${GATEWAY_PORT} still in use" >&2
+ lsof -i tcp:"${GATEWAY_PORT}" >&2 || true
+ exit 1
+fi
+
+docker compose down -v
+echo "e2e-down OK (gateway stopped, docker cleaned)"
diff --git a/scripts/e2e-lib.sh b/scripts/e2e-lib.sh
new file mode 100644
index 0000000..8ff00a9
--- /dev/null
+++ b/scripts/e2e-lib.sh
@@ -0,0 +1,91 @@
+#!/usr/bin/env bash
+# Shared helpers for e2e-run / e2e-up / e2e-down.
+# shellcheck disable=SC2034
+
+e2e_root_dir() {
+ cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd
+}
+
+# Stop gateway started for E2E: pid file → port listeners → stale go run orphans.
+e2e_stop_gateway() {
+ local port="${1:-18888}"
+ local pid_file="${2:-}"
+
+ if [[ -n "${pid_file}" && -f "${pid_file}" ]]; then
+ local pid
+ pid="$(cat "${pid_file}")"
+ if [[ -n "${pid}" ]] && kill -0 "${pid}" 2>/dev/null; then
+ echo ">> stopping gateway pid=${pid}"
+ kill "${pid}" 2>/dev/null || true
+ for _ in $(seq 1 10); do
+ kill -0 "${pid}" 2>/dev/null || break
+ sleep 0.2
+ done
+ if kill -0 "${pid}" 2>/dev/null; then
+ kill -9 "${pid}" 2>/dev/null || true
+ fi
+ wait "${pid}" 2>/dev/null || true
+ fi
+ rm -f "${pid_file}"
+ fi
+
+ if command -v lsof >/dev/null 2>&1; then
+ local pids
+ pids="$(lsof -ti tcp:"${port}" 2>/dev/null | tr '\n' ' ' || true)"
+ if [[ -n "${pids// /}" ]]; then
+ echo ">> stopping listener(s) on :${port} (${pids})"
+ # shellcheck disable=SC2086
+ kill ${pids} 2>/dev/null || true
+ sleep 0.5
+ pids="$(lsof -ti tcp:"${port}" 2>/dev/null | tr '\n' ' ' || true)"
+ if [[ -n "${pids// /}" ]]; then
+ # shellcheck disable=SC2086
+ kill -9 ${pids} 2>/dev/null || true
+ fi
+ fi
+ fi
+
+ # go run leaves a compiled binary under $TMPDIR; kill by e2e config path if still up.
+ if command -v pgrep >/dev/null 2>&1; then
+ while IFS= read -r orphan; do
+ [[ -z "${orphan}" ]] && continue
+ echo ">> stopping orphan gateway pid=${orphan}"
+ kill "${orphan}" 2>/dev/null || true
+ done < <(pgrep -f "gateway(-e2e)? .*${port}|gateway.go -f .*e2e.yaml" 2>/dev/null || true)
+ fi
+}
+
+# Build a real binary so $! is the server PID (go run only tracks the wrapper).
+e2e_start_gateway() {
+ local root="$1"
+ local config="$2"
+ local port="$3"
+ local pid_file="$4"
+
+ local bin="${root}/.cache/e2e-gateway"
+ mkdir -p "${root}/.cache"
+
+ e2e_stop_gateway "${port}" "${pid_file}"
+
+ echo ">> building e2e gateway binary"
+ (cd "${root}" && go build -o "${bin}" gateway.go)
+
+ echo ">> starting gateway on :${port}"
+ GATEWAY_E2E=1 "${bin}" -f "${config}" &
+ local pid=$!
+ echo "${pid}" > "${pid_file}"
+ echo "${pid}"
+}
+
+e2e_wait_gateway() {
+ local port="$1"
+ local url="http://127.0.0.1:${port}/api/v1/health"
+ for i in $(seq 1 60); do
+ if curl -sf "${url}" >/dev/null; then
+ return 0
+ fi
+ sleep 1
+ done
+ echo "timeout waiting for gateway ${url}" >&2
+ return 1
+}
diff --git a/scripts/e2e-run.sh b/scripts/e2e-run.sh
new file mode 100755
index 0000000..d275f6d
--- /dev/null
+++ b/scripts/e2e-run.sh
@@ -0,0 +1,65 @@
+#!/usr/bin/env bash
+# 一鍵 E2E:全新 Docker → index → seed → 起 Gateway → 跑測試 → 關閉並清 volume
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+# shellcheck source=scripts/e2e-lib.sh
+source "${ROOT}/scripts/e2e-lib.sh"
+cd "$ROOT"
+
+E2E_CONFIG="${E2E_CONFIG:-test/e2e/fixtures/e2e.yaml}"
+E2E_STATE="${E2E_STATE:-${ROOT}/test/e2e/fixtures/state.json}"
+E2E_TEST_PATTERN="${E2E_TEST_PATTERN:-Test(Auth_|Health|Member|Permission)}"
+GATEWAY_PORT="${GATEWAY_PORT:-18888}"
+PID_FILE="${PID_FILE:-${ROOT}/test/e2e/fixtures/gateway.pid}"
+
+cleanup() {
+ e2e_stop_gateway "${GATEWAY_PORT}" "${PID_FILE}"
+ if [[ "${E2E_KEEP_DOCKER:-}" != "1" ]]; then
+ echo ">> docker compose down -v"
+ docker compose down -v
+ else
+ echo ">> E2E_KEEP_DOCKER=1: leaving containers running"
+ fi
+}
+trap cleanup EXIT
+
+echo ">> [1/6] fresh docker compose (mongo + redis)"
+docker compose down -v >/dev/null 2>&1 || true
+docker compose up -d mongo redis
+
+echo ">> [2/6] wait for mongo/redis healthy"
+for i in $(seq 1 60); do
+ if docker compose ps mongo 2>/dev/null | grep -q "(healthy)" && docker compose ps redis 2>/dev/null | grep -q "(healthy)"; then
+ break
+ fi
+ sleep 1
+ if [[ "$i" -eq 60 ]]; then
+ echo "timeout waiting for docker health" >&2
+ docker compose ps >&2
+ exit 1
+ fi
+done
+
+echo ">> [3/6] mongo indexes"
+go run ./cmd/mongo-index -f "${E2E_CONFIG}"
+
+echo ">> [4/6] e2e seed (tenant + member + permission + JWT)"
+rm -f "${E2E_STATE}"
+seed_args=(-f "${E2E_CONFIG}" -out "${E2E_STATE}")
+if [[ -n "${E2E_ROLE:-}" ]]; then
+ seed_args+=(-role "${E2E_ROLE}")
+fi
+go run ./cmd/e2e-seed "${seed_args[@]}"
+
+echo ">> [5/6] start gateway on :${GATEWAY_PORT}"
+e2e_start_gateway "${ROOT}" "${E2E_CONFIG}" "${GATEWAY_PORT}" "${PID_FILE}" >/dev/null
+e2e_wait_gateway "${GATEWAY_PORT}"
+
+echo ">> [6/6] run e2e tests (main suite, then auth teardown)"
+GATEWAY_E2E=1 E2E_STATE_FILE="${E2E_STATE}" E2E_BASE_URL="http://127.0.0.1:${GATEWAY_PORT}" \
+ go test -tags=e2e -v -count=1 ./test/e2e/... -run "${E2E_TEST_PATTERN}"
+GATEWAY_E2E=1 E2E_STATE_FILE="${E2E_STATE}" E2E_BASE_URL="http://127.0.0.1:${GATEWAY_PORT}" \
+ go test -tags=e2e -v -count=1 ./test/e2e/... -run 'TestZZZ_AuthTokenRefreshAndLogout'
+
+echo ">> E2E OK"
diff --git a/scripts/e2e-up.sh b/scripts/e2e-up.sh
new file mode 100755
index 0000000..4f28b58
--- /dev/null
+++ b/scripts/e2e-up.sh
@@ -0,0 +1,36 @@
+#!/usr/bin/env bash
+# 啟動 E2E 環境但不跑測試(方便本機除錯)
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+# shellcheck source=scripts/e2e-lib.sh
+source "${ROOT}/scripts/e2e-lib.sh"
+cd "$ROOT"
+
+E2E_CONFIG="${E2E_CONFIG:-test/e2e/fixtures/e2e.yaml}"
+E2E_STATE="${E2E_STATE:-${ROOT}/test/e2e/fixtures/state.json}"
+GATEWAY_PORT="${GATEWAY_PORT:-18888}"
+PID_FILE="${PID_FILE:-${ROOT}/test/e2e/fixtures/gateway.pid}"
+
+docker compose down -v >/dev/null 2>&1 || true
+docker compose up -d mongo redis
+
+for i in $(seq 1 60); do
+ if docker compose ps mongo 2>/dev/null | grep -q "(healthy)" && docker compose ps redis 2>/dev/null | grep -q "(healthy)"; then
+ break
+ fi
+ sleep 1
+done
+
+go run ./cmd/mongo-index -f "${E2E_CONFIG}"
+go run ./cmd/e2e-seed -f "${E2E_CONFIG}" -out "${E2E_STATE}"
+
+if [[ -f "${PID_FILE}" ]] && kill -0 "$(cat "${PID_FILE}")" 2>/dev/null && curl -sf "http://127.0.0.1:${GATEWAY_PORT}/api/v1/health" >/dev/null; then
+ echo "gateway already running pid=$(cat "${PID_FILE}") url=http://127.0.0.1:${GATEWAY_PORT}"
+else
+ pid="$(e2e_start_gateway "${ROOT}" "${E2E_CONFIG}" "${GATEWAY_PORT}" "${PID_FILE}")"
+ echo "gateway started pid=${pid} url=http://127.0.0.1:${GATEWAY_PORT}"
+fi
+
+e2e_wait_gateway "${GATEWAY_PORT}"
+echo "e2e-up OK — run: make test-e2e"
diff --git a/test/e2e/auth_test.go b/test/e2e/auth_test.go
new file mode 100644
index 0000000..e099de7
--- /dev/null
+++ b/test/e2e/auth_test.go
@@ -0,0 +1,91 @@
+//go:build e2e
+
+package e2e
+
+import (
+ "encoding/json"
+ "net/http"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// TestZZZ_AuthTokenRefreshAndLogout runs last (separate go test invocation).
+// It uses an isolated refresh so seed tokens used by member/permission stay valid.
+func TestZZZ_AuthTokenRefreshAndLogout(t *testing.T) {
+ c := isolatedAuthClient(t)
+
+ refreshEnv := c.DoExpectOK(t, http.MethodPost, "/api/v1/auth/token/refresh", map[string]string{
+ "refresh_token": c.Fixture.RefreshToken,
+ }, false)
+ var pair struct {
+ AccessToken string `json:"access_token"`
+ RefreshToken string `json:"refresh_token"`
+ UID string `json:"uid"`
+ }
+ require.NoError(t, json.Unmarshal(refreshEnv.Data, &pair))
+ require.Equal(t, c.Fixture.UID, pair.UID)
+
+ c.Fixture.AccessToken = pair.AccessToken
+ c.Fixture.RefreshToken = pair.RefreshToken
+
+ c.DoExpectOK(t, http.MethodGet, "/api/v1/members/me", nil, true)
+ c.DoExpectOK(t, http.MethodPost, "/api/v1/auth/logout", nil, true)
+
+ resp, env := c.Do(t, http.MethodGet, "/api/v1/members/me", nil, true)
+ require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
+ require.NotEqual(t, int64(successCode), env.Code)
+}
+
+func TestAuth_MissingBearer_401(t *testing.T) {
+ c := NewClient(t)
+ resp, env := c.Do(t, http.MethodGet, "/api/v1/members/me", nil, false)
+ require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
+ require.NotEqual(t, int64(successCode), env.Code)
+}
+
+func TestAuth_PublicValidationErrors(t *testing.T) {
+ c := NewClient(t)
+
+ cases := []struct {
+ name string
+ path string
+ body any
+ }{
+ {
+ name: "register missing required fields",
+ path: "/api/v1/auth/register",
+ body: map[string]any{},
+ },
+ {
+ name: "login invalid email and password",
+ path: "/api/v1/auth/login",
+ body: map[string]any{
+ "tenant_slug": c.Fixture.TenantSlug,
+ "email": "not-an-email",
+ "password": "short",
+ },
+ },
+ {
+ name: "token refresh missing token",
+ path: "/api/v1/auth/token/refresh",
+ body: map[string]any{},
+ },
+ {
+ name: "social login invalid provider",
+ path: "/api/v1/auth/login/social/start",
+ body: map[string]any{
+ "tenant_slug": c.Fixture.TenantSlug,
+ "provider": "github",
+ "redirect_uri": "http://127.0.0.1/callback",
+ },
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ env := c.DoExpectHTTP(t, http.MethodPost, tc.path, tc.body, false, http.StatusBadRequest)
+ require.NotEqual(t, int64(successCode), env.Code)
+ })
+ }
+}
diff --git a/test/e2e/client.go b/test/e2e/client.go
new file mode 100644
index 0000000..0fd22cc
--- /dev/null
+++ b/test/e2e/client.go
@@ -0,0 +1,144 @@
+//go:build e2e
+
+package e2e
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+const successCode = 102000
+
+// Fixture holds seed output from cmd/e2e-seed.
+type Fixture struct {
+ BaseURL string `json:"base_url"`
+ TenantID string `json:"tenant_id"`
+ TenantSlug string `json:"tenant_slug"`
+ UID string `json:"uid"`
+ Email string `json:"email"`
+ AccessToken string `json:"access_token"`
+ RefreshToken string `json:"refresh_token"`
+ RoleKey string `json:"role_key"`
+ NoRoleUID string `json:"no_role_uid"`
+ NoRoleEmail string `json:"no_role_email"`
+ NoRoleAccessToken string `json:"no_role_access_token"`
+ NoRoleRefreshToken string `json:"no_role_refresh_token"`
+}
+
+// Client is a thin HTTP helper for Gateway E2E tests.
+type Client struct {
+ BaseURL string
+ HTTP *http.Client
+ Fixture Fixture
+}
+
+// Envelope matches gateway/types.Status JSON.
+type Envelope struct {
+ Code int64 `json:"code"`
+ Message string `json:"message"`
+ Data json.RawMessage `json:"data"`
+ Error json.RawMessage `json:"error,omitempty"`
+}
+
+// LoadFixture returns the shared bootstrap fixture (refreshed once in TestMain).
+func LoadFixture(t *testing.T) Fixture {
+ t.Helper()
+ fx := loadFixture()
+ require.NotEmpty(t, fx.BaseURL)
+ require.NotEmpty(t, fx.AccessToken)
+ return fx
+}
+
+// NewClient builds a client from the shared fixture.
+func NewClient(t *testing.T) *Client {
+ t.Helper()
+ return freshClient(t)
+}
+
+// NewNoRoleClient builds a client for the seeded member that intentionally
+// has no role assignment. It is used by Casbin-enabled authorization tests.
+func NewNoRoleClient(t *testing.T) *Client {
+ t.Helper()
+ c := freshClient(t)
+ require.NotEmpty(t, c.Fixture.NoRoleUID)
+ require.NotEmpty(t, c.Fixture.NoRoleAccessToken)
+ c.Fixture.UID = c.Fixture.NoRoleUID
+ c.Fixture.Email = c.Fixture.NoRoleEmail
+ c.Fixture.AccessToken = c.Fixture.NoRoleAccessToken
+ c.Fixture.RefreshToken = c.Fixture.NoRoleRefreshToken
+ return c
+}
+
+func (c *Client) URL(path string) string {
+ return c.BaseURL + path
+}
+
+func (c *Client) Do(t *testing.T, method, path string, body any, auth bool) (*http.Response, Envelope) {
+ t.Helper()
+ var r io.Reader
+ if body != nil {
+ raw, err := json.Marshal(body)
+ require.NoError(t, err)
+ r = bytes.NewReader(raw)
+ }
+ req, err := http.NewRequest(method, c.URL(path), r)
+ require.NoError(t, err)
+ if body != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ if auth {
+ req.Header.Set("Authorization", "Bearer "+c.Fixture.AccessToken)
+ }
+ resp, err := c.HTTP.Do(req)
+ require.NoError(t, err)
+ defer resp.Body.Close()
+ respBody, err := io.ReadAll(resp.Body)
+ require.NoError(t, err)
+ var env Envelope
+ require.NoError(t, json.Unmarshal(respBody, &env), "body=%s", string(respBody))
+ return resp, env
+}
+
+func (c *Client) DoExpectOK(t *testing.T, method, path string, body any, auth bool) Envelope {
+ t.Helper()
+ resp, env := c.Do(t, method, path, body, auth)
+ require.Equal(t, http.StatusOK, resp.StatusCode, "path=%s code=%d body=%s", path, env.Code, string(mustRaw(env)))
+ require.Equal(t, int64(successCode), env.Code, "path=%s message=%s", path, env.Message)
+ return env
+}
+
+func (c *Client) DoExpectHTTP(t *testing.T, method, path string, body any, auth bool, httpStatus int) Envelope {
+ t.Helper()
+ resp, env := c.Do(t, method, path, body, auth)
+ require.Equal(t, httpStatus, resp.StatusCode, "path=%s env=%+v", path, env)
+ return env
+}
+
+func mustRaw(env Envelope) []byte {
+ if len(env.Data) == 0 {
+ return []byte(env.Message)
+ }
+ return env.Data
+}
+
+// FetchE2EOTP reads OTP plain code stashed by verify_helper when GATEWAY_E2E=1.
+func FetchE2EOTP(t *testing.T, challengeID string) string {
+ t.Helper()
+ if cli := os.Getenv("REDISCLI"); cli != "" {
+ out, err := runCmd(cli, "-c", fmt.Sprintf("GET e2e:otp:%s", challengeID))
+ require.NoError(t, err)
+ require.NotEmpty(t, out, "missing e2e otp for challenge %s", challengeID)
+ return out
+ }
+ out, err := runCmd("docker", "exec", "gateway-redis", "redis-cli", "GET", "e2e:otp:"+challengeID)
+ require.NoError(t, err, "fetch otp via docker exec (is gateway-redis running?)")
+ require.NotEmpty(t, out, "missing e2e otp for challenge %s", challengeID)
+ return out
+}
diff --git a/test/e2e/exec.go b/test/e2e/exec.go
new file mode 100644
index 0000000..00e06f8
--- /dev/null
+++ b/test/e2e/exec.go
@@ -0,0 +1,17 @@
+//go:build e2e
+
+package e2e
+
+import (
+ "os/exec"
+ "strings"
+)
+
+func runCmd(name string, args ...string) (string, error) {
+ cmd := exec.Command(name, args...)
+ out, err := cmd.Output()
+ if err != nil {
+ return "", err
+ }
+ return strings.TrimSpace(string(out)), nil
+}
diff --git a/test/e2e/fixtures/e2e.casbin.yaml b/test/e2e/fixtures/e2e.casbin.yaml
new file mode 100644
index 0000000..6489c36
--- /dev/null
+++ b/test/e2e/fixtures/e2e.casbin.yaml
@@ -0,0 +1,93 @@
+# E2E 專用設定(Casbin enabled;make e2e-casbin 使用)
+# 固定 Port 18888,避免與本機 dev server (8888) 衝突
+
+Name: gateway-e2e-casbin
+Host: 0.0.0.0
+Port: 18888
+
+Mongo:
+ Schema: mongodb
+ Host: 127.0.0.1
+ Port: 27017
+ Database: gateway_e2e
+ AuthSource: ""
+ ReplicaName: ""
+ TLS: false
+ MaxPoolSize: 30
+ MinPoolSize: 5
+ MaxConnIdleTime: 30m
+
+Redis:
+ Host: localhost:6379
+ Type: node
+
+Notification:
+ DefaultLocale: zh-tw
+ Email:
+ Provider: mock
+ From: e2e-noreply@example.com
+ SMTP:
+ Enable: false
+ SMS:
+ Provider: mock
+ Mitake:
+ Enable: false
+ Async:
+ Worker: 1
+ MaxRetry: 3
+ BackoffSeconds: [1, 2, 5]
+ RatePerTenant:
+ Email: 1000
+ SMS: 500
+
+Member:
+ OTP:
+ Length: 6
+ TTLSeconds: 300
+ MaxAttempts: 5
+ ResendCooldownSeconds: 1
+ DailyVerifyLimit: 100
+ TOTP:
+ Issuer: CloudEP-E2E
+ Algorithm: SHA1
+ Digits: 6
+ PeriodSeconds: 30
+ Window: 1
+ BackupCodeCount: 5
+ BackupCodeLength: 12
+ EnrollTTLSeconds: 600
+ ReplayTTLSeconds: 90
+ SecretKEK: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"
+ Registration:
+ RequireInviteCode: false
+ TrustSocialEmailVerified: true
+
+Auth:
+ AccessExpire: 900
+ RefreshExpire: 604800
+ ActiveKID: v1
+ AccessSecret: "e2e-access-secret-32-bytes-min!!"
+ RefreshSecret: "e2e-refresh-secret-32-bytes-min!"
+ RegistrationSessionTTLSeconds: 600
+
+Permission:
+ Casbin:
+ Enabled: true
+ ModelPath: etc/rbac.conf
+ PolicyAdapter: redis
+ Cache:
+ UserRolesTTLSeconds: 60
+ RolePermsTTLSeconds: 60
+ CatalogTTLSeconds: 120
+ Reload:
+ Channel: casbin:reload:e2e
+ DebounceMilliseconds: 100
+ HeartbeatSeconds: 30
+
+Zitadel:
+ Issuer: ""
+ ServiceUserToken: ""
+ DefaultOrgID: ""
+ OAuthClientID: ""
+ OAuthClientSecret: ""
+ TimeoutSeconds: 5
diff --git a/test/e2e/fixtures/e2e.yaml b/test/e2e/fixtures/e2e.yaml
new file mode 100644
index 0000000..3062d2b
--- /dev/null
+++ b/test/e2e/fixtures/e2e.yaml
@@ -0,0 +1,93 @@
+# E2E 專用設定(make e2e-full 使用;勿與 gateway.dev.yaml 混用)
+# 固定 Port 18888,避免與本機 dev server (8888) 衝突
+
+Name: gateway-e2e
+Host: 0.0.0.0
+Port: 18888
+
+Mongo:
+ Schema: mongodb
+ Host: 127.0.0.1
+ Port: 27017
+ Database: gateway_e2e
+ AuthSource: ""
+ ReplicaName: ""
+ TLS: false
+ MaxPoolSize: 30
+ MinPoolSize: 5
+ MaxConnIdleTime: 30m
+
+Redis:
+ Host: localhost:6379
+ Type: node
+
+Notification:
+ DefaultLocale: zh-tw
+ Email:
+ Provider: mock
+ From: e2e-noreply@example.com
+ SMTP:
+ Enable: false
+ SMS:
+ Provider: mock
+ Mitake:
+ Enable: false
+ Async:
+ Worker: 1
+ MaxRetry: 3
+ BackoffSeconds: [1, 2, 5]
+ RatePerTenant:
+ Email: 1000
+ SMS: 500
+
+Member:
+ OTP:
+ Length: 6
+ TTLSeconds: 300
+ MaxAttempts: 5
+ ResendCooldownSeconds: 1
+ DailyVerifyLimit: 100
+ TOTP:
+ Issuer: CloudEP-E2E
+ Algorithm: SHA1
+ Digits: 6
+ PeriodSeconds: 30
+ Window: 1
+ BackupCodeCount: 5
+ BackupCodeLength: 12
+ EnrollTTLSeconds: 600
+ ReplayTTLSeconds: 90
+ SecretKEK: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"
+ Registration:
+ RequireInviteCode: false
+ TrustSocialEmailVerified: true
+
+Auth:
+ AccessExpire: 900
+ RefreshExpire: 604800
+ ActiveKID: v1
+ AccessSecret: "e2e-access-secret-32-bytes-min!!"
+ RefreshSecret: "e2e-refresh-secret-32-bytes-min!"
+ RegistrationSessionTTLSeconds: 600
+
+Permission:
+ Casbin:
+ Enabled: false
+ ModelPath: etc/rbac.conf
+ PolicyAdapter: auto
+ Cache:
+ UserRolesTTLSeconds: 60
+ RolePermsTTLSeconds: 60
+ CatalogTTLSeconds: 120
+ Reload:
+ Channel: casbin:reload:e2e
+ DebounceMilliseconds: 100
+ HeartbeatSeconds: 30
+
+Zitadel:
+ Issuer: ""
+ ServiceUserToken: ""
+ DefaultOrgID: ""
+ OAuthClientID: ""
+ OAuthClientSecret: ""
+ TimeoutSeconds: 5
diff --git a/test/e2e/health_test.go b/test/e2e/health_test.go
new file mode 100644
index 0000000..da75406
--- /dev/null
+++ b/test/e2e/health_test.go
@@ -0,0 +1,25 @@
+//go:build e2e
+
+package e2e
+
+import (
+ "encoding/json"
+ "net/http"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestHealth_Ping(t *testing.T) {
+ c := NewClient(t)
+ env := c.DoExpectOK(t, http.MethodGet, "/api/v1/health", nil, false)
+ var data map[string]any
+ require.NoError(t, json.Unmarshal(env.Data, &data))
+}
+
+func TestHealth_NoAuthRequired(t *testing.T) {
+ c := NewClient(t)
+ resp, env := c.Do(t, http.MethodGet, "/api/v1/health", nil, false)
+ require.Equal(t, http.StatusOK, resp.StatusCode)
+ require.Equal(t, int64(successCode), env.Code)
+}
diff --git a/test/e2e/member_test.go b/test/e2e/member_test.go
new file mode 100644
index 0000000..5f40fdb
--- /dev/null
+++ b/test/e2e/member_test.go
@@ -0,0 +1,194 @@
+//go:build e2e
+
+package e2e
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/url"
+ "strconv"
+ "testing"
+ "time"
+
+ membertotp "gateway/internal/model/member/totp"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestMember_GetMe(t *testing.T) {
+ c := NewClient(t)
+ env := c.DoExpectOK(t, http.MethodGet, "/api/v1/members/me", nil, true)
+ var me struct {
+ TenantID string `json:"tenant_id"`
+ UID string `json:"uid"`
+ Status string `json:"status"`
+ }
+ require.NoError(t, json.Unmarshal(env.Data, &me))
+ require.Equal(t, c.Fixture.TenantID, me.TenantID)
+ require.Equal(t, c.Fixture.UID, me.UID)
+ require.Equal(t, "active", me.Status)
+}
+
+func TestMember_UpdateMe(t *testing.T) {
+ c := NewClient(t)
+ name := "E2E Updated Name"
+ env := c.DoExpectOK(t, http.MethodPatch, "/api/v1/members/me", map[string]string{
+ "display_name": name,
+ }, true)
+ var me struct {
+ DisplayName string `json:"display_name"`
+ }
+ require.NoError(t, json.Unmarshal(env.Data, &me))
+ require.Equal(t, name, me.DisplayName)
+}
+
+func TestMember_EmailVerification_FullFlow(t *testing.T) {
+ c := NewClient(t)
+ target := "verified-e2e@example.com"
+
+ startEnv := c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/verifications/email/start", map[string]string{
+ "target": target,
+ }, true)
+ var start struct {
+ ChallengeID string `json:"challenge_id"`
+ ExpiresIn int `json:"expires_in"`
+ }
+ require.NoError(t, json.Unmarshal(startEnv.Data, &start))
+ require.NotEmpty(t, start.ChallengeID)
+ require.Positive(t, start.ExpiresIn)
+
+ code := FetchE2EOTP(t, start.ChallengeID)
+ c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/verifications/email/confirm", map[string]string{
+ "challenge_id": start.ChallengeID,
+ "code": code,
+ }, true)
+
+ env := c.DoExpectOK(t, http.MethodGet, "/api/v1/members/me", nil, true)
+ var me struct {
+ BusinessEmail string `json:"business_email"`
+ BusinessEmailVerified bool `json:"business_email_verified"`
+ }
+ require.NoError(t, json.Unmarshal(env.Data, &me))
+ require.Equal(t, target, me.BusinessEmail)
+ require.True(t, me.BusinessEmailVerified)
+}
+
+func TestMember_PhoneVerification_FullFlow(t *testing.T) {
+ c := NewClient(t)
+ target := "+886912345678"
+
+ startEnv := c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/verifications/phone/start", map[string]string{
+ "target": target,
+ }, true)
+ var start struct {
+ ChallengeID string `json:"challenge_id"`
+ ExpiresIn int `json:"expires_in"`
+ }
+ require.NoError(t, json.Unmarshal(startEnv.Data, &start))
+ require.NotEmpty(t, start.ChallengeID)
+ require.Positive(t, start.ExpiresIn)
+
+ code := FetchE2EOTP(t, start.ChallengeID)
+ c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/verifications/phone/confirm", map[string]string{
+ "challenge_id": start.ChallengeID,
+ "code": code,
+ }, true)
+
+ env := c.DoExpectOK(t, http.MethodGet, "/api/v1/members/me", nil, true)
+ var me struct {
+ BusinessPhone string `json:"business_phone"`
+ BusinessPhoneVerified bool `json:"business_phone_verified"`
+ }
+ require.NoError(t, json.Unmarshal(env.Data, &me))
+ require.Equal(t, target, me.BusinessPhone)
+ require.True(t, me.BusinessPhoneVerified)
+}
+
+func TestMember_TOTP_Status(t *testing.T) {
+ c := NewClient(t)
+ env := c.DoExpectOK(t, http.MethodGet, "/api/v1/members/me/totp", nil, true)
+ var st struct {
+ Enrolled bool `json:"enrolled"`
+ }
+ require.NoError(t, json.Unmarshal(env.Data, &st))
+ require.False(t, st.Enrolled)
+}
+
+func TestMember_TOTP_FullFlow(t *testing.T) {
+ c := NewClient(t)
+
+ startEnv := c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/totp/enroll-start", nil, true)
+ var start struct {
+ OtpauthURL string `json:"otpauth_url"`
+ Digits int `json:"digits"`
+ PeriodSec int `json:"period_seconds"`
+ }
+ require.NoError(t, json.Unmarshal(startEnv.Data, &start))
+ require.NotEmpty(t, start.OtpauthURL)
+ require.Positive(t, start.Digits)
+ require.Positive(t, start.PeriodSec)
+
+ code := codeFromOtpauthURL(t, start.OtpauthURL, start.Digits, start.PeriodSec)
+ confirmEnv := c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/totp/enroll-confirm", map[string]string{
+ "code": code,
+ }, true)
+ var confirmed struct {
+ BackupCodes []string `json:"backup_codes"`
+ }
+ require.NoError(t, json.Unmarshal(confirmEnv.Data, &confirmed))
+ require.NotEmpty(t, confirmed.BackupCodes)
+
+ statusEnv := c.DoExpectOK(t, http.MethodGet, "/api/v1/members/me/totp", nil, true)
+ var status struct {
+ Enrolled bool `json:"enrolled"`
+ BackupCodesRemaining int `json:"backup_codes_remaining"`
+ }
+ require.NoError(t, json.Unmarshal(statusEnv.Data, &status))
+ require.True(t, status.Enrolled)
+ require.Equal(t, len(confirmed.BackupCodes), status.BackupCodesRemaining)
+
+ c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/totp/verify", map[string]string{
+ "code": code,
+ }, true)
+
+ replayEnv := c.DoExpectHTTP(t, http.MethodPost, "/api/v1/members/me/totp/verify", map[string]string{
+ "code": code,
+ }, true, http.StatusForbidden)
+ require.NotEqual(t, int64(successCode), replayEnv.Code)
+
+ backupEnv := c.DoExpectOK(t, http.MethodPost, "/api/v1/members/me/totp/backup-codes", nil, true)
+ var backup struct {
+ BackupCodes []string `json:"backup_codes"`
+ }
+ require.NoError(t, json.Unmarshal(backupEnv.Data, &backup))
+ require.NotEmpty(t, backup.BackupCodes)
+
+ c.DoExpectOK(t, http.MethodDelete, "/api/v1/members/me/totp", nil, true)
+ finalEnv := c.DoExpectOK(t, http.MethodGet, "/api/v1/members/me/totp", nil, true)
+ var finalStatus struct {
+ Enrolled bool `json:"enrolled"`
+ }
+ require.NoError(t, json.Unmarshal(finalEnv.Data, &finalStatus))
+ require.False(t, finalStatus.Enrolled)
+}
+
+func codeFromOtpauthURL(t *testing.T, rawURL string, digits, periodSec int) string {
+ t.Helper()
+ u, err := url.Parse(rawURL)
+ require.NoError(t, err)
+ require.Equal(t, "otpauth", u.Scheme)
+ require.Equal(t, "totp", u.Host)
+
+ q := u.Query()
+ secret, err := membertotp.DecodeSecret(q.Get("secret"))
+ require.NoError(t, err)
+ if digits <= 0 {
+ digits, _ = strconv.Atoi(q.Get("digits"))
+ }
+ if periodSec <= 0 {
+ periodSec, _ = strconv.Atoi(q.Get("period"))
+ }
+ code, err := membertotp.Generate(secret, time.Now(), time.Duration(periodSec)*time.Second, digits)
+ require.NoError(t, err)
+ return code
+}
diff --git a/test/e2e/permission_test.go b/test/e2e/permission_test.go
new file mode 100644
index 0000000..f28249d
--- /dev/null
+++ b/test/e2e/permission_test.go
@@ -0,0 +1,249 @@
+//go:build e2e
+
+package e2e
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "os"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestPermission_Catalog(t *testing.T) {
+ c := NewClient(t)
+ env := c.DoExpectOK(t, http.MethodGet, "/api/v1/permissions/catalog?tree=true", nil, true)
+ var data struct {
+ Tree []map[string]any `json:"tree"`
+ }
+ require.NoError(t, json.Unmarshal(env.Data, &data))
+ require.NotEmpty(t, data.Tree)
+}
+
+func TestPermission_Me(t *testing.T) {
+ c := NewClient(t)
+ env := c.DoExpectOK(t, http.MethodGet, "/api/v1/permissions/me?include_tree=true", nil, true)
+ var data struct {
+ UID string `json:"uid"`
+ TenantID string `json:"tenant_id"`
+ Roles []string `json:"roles"`
+ Permissions map[string]string `json:"permissions"`
+ Tree []map[string]any `json:"tree"`
+ }
+ require.NoError(t, json.Unmarshal(env.Data, &data))
+ require.Equal(t, c.Fixture.UID, data.UID)
+ require.Equal(t, c.Fixture.TenantID, data.TenantID)
+ require.Contains(t, data.Roles, c.Fixture.RoleKey)
+ require.NotEmpty(t, data.Permissions)
+}
+
+func TestPermission_RoleCRUD(t *testing.T) {
+ c := NewClient(t)
+
+ createEnv := c.DoExpectOK(t, http.MethodPost, "/api/v1/permissions/roles", map[string]string{
+ "key": "e2e_custom_role",
+ "display_name": "E2E Custom",
+ "status": "open",
+ }, true)
+ var role struct {
+ ID string `json:"id"`
+ Key string `json:"key"`
+ }
+ require.NoError(t, json.Unmarshal(createEnv.Data, &role))
+ require.Equal(t, "e2e_custom_role", role.Key)
+ require.NotEmpty(t, role.ID)
+
+ listEnv := c.DoExpectOK(t, http.MethodGet, "/api/v1/permissions/roles", nil, true)
+ var list struct {
+ Roles []struct {
+ Key string `json:"key"`
+ } `json:"roles"`
+ }
+ require.NoError(t, json.Unmarshal(listEnv.Data, &list))
+ found := false
+ for _, r := range list.Roles {
+ if r.Key == "e2e_custom_role" {
+ found = true
+ break
+ }
+ }
+ require.True(t, found, "created role should appear in list")
+
+ patchEnv := c.DoExpectOK(t, http.MethodPatch, "/api/v1/permissions/roles/"+role.ID, map[string]string{
+ "display_name": "E2E Custom Renamed",
+ }, true)
+ var patched struct {
+ DisplayName string `json:"display_name"`
+ }
+ require.NoError(t, json.Unmarshal(patchEnv.Data, &patched))
+ require.Equal(t, "E2E Custom Renamed", patched.DisplayName)
+
+ c.DoExpectOK(t, http.MethodDelete, "/api/v1/permissions/roles/"+role.ID, nil, true)
+}
+
+func TestPermission_RolePermissions(t *testing.T) {
+ c := NewClient(t)
+ permissionID := firstCatalogPermissionID(t, c)
+
+ createEnv := c.DoExpectOK(t, http.MethodPost, "/api/v1/permissions/roles", map[string]string{
+ "key": "e2e_role_permissions",
+ "display_name": "E2E Role Permissions",
+ }, true)
+ var role struct {
+ ID string `json:"id"`
+ }
+ require.NoError(t, json.Unmarshal(createEnv.Data, &role))
+ require.NotEmpty(t, role.ID)
+
+ c.DoExpectOK(t, http.MethodPut, "/api/v1/permissions/roles/"+role.ID+"/permissions", map[string][]string{
+ "permission_ids": {permissionID},
+ }, true)
+
+ listEnv := c.DoExpectOK(t, http.MethodGet, "/api/v1/permissions/roles/"+role.ID+"/permissions", nil, true)
+ var list struct {
+ Permissions []struct {
+ ID string `json:"id"`
+ } `json:"permissions"`
+ }
+ require.NoError(t, json.Unmarshal(listEnv.Data, &list))
+ require.NotEmpty(t, list.Permissions)
+ found := false
+ for _, p := range list.Permissions {
+ if p.ID == permissionID {
+ found = true
+ break
+ }
+ }
+ require.True(t, found, "role should include requested permission")
+
+ c.DoExpectOK(t, http.MethodDelete, "/api/v1/permissions/roles/"+role.ID, nil, true)
+}
+
+func TestPermission_AssignUserRole(t *testing.T) {
+ c := NewClient(t)
+
+ createEnv := c.DoExpectOK(t, http.MethodPost, "/api/v1/permissions/roles", map[string]string{
+ "key": "e2e_assign_role",
+ "display_name": "E2E Assign",
+ }, true)
+ var role struct {
+ ID string `json:"id"`
+ }
+ require.NoError(t, json.Unmarshal(createEnv.Data, &role))
+
+ c.DoExpectOK(t, http.MethodPost, "/api/v1/permissions/users/"+c.Fixture.UID+"/roles", map[string]string{
+ "role_id": role.ID,
+ }, true)
+
+ listEnv := c.DoExpectOK(t, http.MethodGet, "/api/v1/permissions/users/"+c.Fixture.UID+"/roles", nil, true)
+ var list struct {
+ UserRoles []struct {
+ RoleID string `json:"role_id"`
+ } `json:"user_roles"`
+ }
+ require.NoError(t, json.Unmarshal(listEnv.Data, &list))
+ found := false
+ for _, r := range list.UserRoles {
+ if r.RoleID == role.ID {
+ found = true
+ break
+ }
+ }
+ require.True(t, found)
+
+ c.DoExpectOK(t, http.MethodDelete, "/api/v1/permissions/users/"+c.Fixture.UID+"/roles/"+role.ID, nil, true)
+ c.DoExpectOK(t, http.MethodDelete, "/api/v1/permissions/roles/"+role.ID, nil, true)
+}
+
+func TestPermission_RoleMappingCRUD(t *testing.T) {
+ c := NewClient(t)
+ roleKey := "e2e_mapping_role"
+ externalKey := fmt.Sprintf("e2e-group-%s", c.Fixture.UID)
+
+ createEnv := c.DoExpectOK(t, http.MethodPost, "/api/v1/permissions/roles", map[string]string{
+ "key": roleKey,
+ "display_name": "E2E Mapping Role",
+ }, true)
+ var role struct {
+ ID string `json:"id"`
+ }
+ require.NoError(t, json.Unmarshal(createEnv.Data, &role))
+ require.NotEmpty(t, role.ID)
+
+ upsertEnv := c.DoExpectOK(t, http.MethodPut, "/api/v1/permissions/role-mappings", map[string]string{
+ "external_source": "zitadel",
+ "external_key": externalKey,
+ "internal_role_key": roleKey,
+ }, true)
+ var mapping struct {
+ ID string `json:"id"`
+ ExternalSource string `json:"external_source"`
+ ExternalKey string `json:"external_key"`
+ InternalRoleID string `json:"internal_role_id"`
+ InternalRoleKey string `json:"internal_role_key"`
+ }
+ require.NoError(t, json.Unmarshal(upsertEnv.Data, &mapping))
+ require.NotEmpty(t, mapping.ID)
+ require.Equal(t, "zitadel", mapping.ExternalSource)
+ require.Equal(t, externalKey, mapping.ExternalKey)
+ require.Equal(t, role.ID, mapping.InternalRoleID)
+ require.Equal(t, roleKey, mapping.InternalRoleKey)
+
+ listEnv := c.DoExpectOK(t, http.MethodGet, "/api/v1/permissions/role-mappings?source=zitadel", nil, true)
+ var list struct {
+ Mappings []struct {
+ ExternalKey string `json:"external_key"`
+ } `json:"mappings"`
+ }
+ require.NoError(t, json.Unmarshal(listEnv.Data, &list))
+ found := false
+ for _, item := range list.Mappings {
+ if item.ExternalKey == externalKey {
+ found = true
+ break
+ }
+ }
+ require.True(t, found, "created role mapping should appear in list")
+
+ c.DoExpectOK(t, http.MethodDelete, "/api/v1/permissions/role-mappings", map[string]string{
+ "external_source": "zitadel",
+ "external_key": externalKey,
+ }, true)
+ c.DoExpectOK(t, http.MethodDelete, "/api/v1/permissions/roles/"+role.ID, nil, true)
+}
+
+func TestPermission_CasbinRBAC(t *testing.T) {
+ if os.Getenv("E2E_CASBIN") != "1" {
+ t.Skip("set E2E_CASBIN=1 and use e2e.casbin.yaml to enable Casbin enforcement")
+ }
+
+ owner := NewClient(t)
+ reloadEnv := owner.DoExpectOK(t, http.MethodPost, "/api/v1/permissions/policy/reload", nil, true)
+ var reload struct {
+ Tenant string `json:"tenant"`
+ TS int64 `json:"ts"`
+ }
+ require.NoError(t, json.Unmarshal(reloadEnv.Data, &reload))
+ require.Equal(t, owner.Fixture.TenantID, reload.Tenant)
+ require.Positive(t, reload.TS)
+
+ noRole := NewNoRoleClient(t)
+ denied := noRole.DoExpectHTTP(t, http.MethodGet, "/api/v1/permissions/roles", nil, true, http.StatusForbidden)
+ require.NotEqual(t, int64(successCode), denied.Code)
+}
+
+func firstCatalogPermissionID(t *testing.T, c *Client) string {
+ t.Helper()
+ env := c.DoExpectOK(t, http.MethodGet, "/api/v1/permissions/catalog?tree=false", nil, true)
+ var data struct {
+ List []struct {
+ ID string `json:"id"`
+ } `json:"list"`
+ }
+ require.NoError(t, json.Unmarshal(env.Data, &data))
+ require.NotEmpty(t, data.List)
+ require.NotEmpty(t, data.List[0].ID)
+ return data.List[0].ID
+}
diff --git a/test/e2e/setup_test.go b/test/e2e/setup_test.go
new file mode 100644
index 0000000..21e8697
--- /dev/null
+++ b/test/e2e/setup_test.go
@@ -0,0 +1,132 @@
+//go:build e2e
+
+package e2e
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+var sharedFixture Fixture
+
+func TestMain(m *testing.M) {
+ if err := loadSharedFixture(); err != nil {
+ fmt.Fprintf(os.Stderr, "e2e bootstrap: %v\n", err)
+ os.Exit(1)
+ }
+ os.Exit(m.Run())
+}
+
+func loadSharedFixture() error {
+ path := os.Getenv("E2E_STATE_FILE")
+ if path == "" {
+ path = filepath.Join("fixtures", "state.json")
+ }
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ return fmt.Errorf("read %s: %w (run make e2e-full)", path, err)
+ }
+ if err := json.Unmarshal(raw, &sharedFixture); err != nil {
+ return err
+ }
+ if sharedFixture.BaseURL == "" || sharedFixture.AccessToken == "" {
+ return fmt.Errorf("invalid fixture in %s", path)
+ }
+ if override := os.Getenv("E2E_BASE_URL"); override != "" {
+ sharedFixture.BaseURL = override
+ }
+ return nil
+}
+
+func refreshTokenPair(baseURL, refreshToken string) (Fixture, error) {
+ body, _ := json.Marshal(map[string]string{"refresh_token": refreshToken})
+ req, err := http.NewRequest(http.MethodPost, baseURL+"/api/v1/auth/token/refresh", bytes.NewReader(body))
+ if err != nil {
+ return Fixture{}, err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ client := &http.Client{Timeout: 15 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return Fixture{}, err
+ }
+ defer resp.Body.Close()
+ raw, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return Fixture{}, err
+ }
+ var env Envelope
+ if err := json.Unmarshal(raw, &env); err != nil {
+ return Fixture{}, err
+ }
+ if resp.StatusCode != http.StatusOK || env.Code != successCode {
+ return Fixture{}, fmt.Errorf("refresh failed: status=%d code=%d", resp.StatusCode, env.Code)
+ }
+ var data struct {
+ AccessToken string `json:"access_token"`
+ RefreshToken string `json:"refresh_token"`
+ }
+ if err := json.Unmarshal(env.Data, &data); err != nil {
+ return Fixture{}, err
+ }
+ out := Fixture{AccessToken: data.AccessToken, RefreshToken: data.RefreshToken}
+ if out.AccessToken == "" || out.RefreshToken == "" {
+ return Fixture{}, fmt.Errorf("refresh returned empty tokens")
+ }
+ return out, nil
+}
+
+func loadFixture() Fixture {
+ return sharedFixture
+}
+
+func freshClient(t *testing.T) *Client {
+ t.Helper()
+ fx := loadFixture()
+ base := fx.BaseURL
+ if override := os.Getenv("E2E_BASE_URL"); override != "" {
+ base = override
+ }
+ return &Client{
+ BaseURL: base,
+ HTTP: &http.Client{Timeout: 15 * time.Second},
+ Fixture: fx,
+ }
+}
+
+func isolatedAuthClient(t *testing.T) *Client {
+ t.Helper()
+ path := os.Getenv("E2E_STATE_FILE")
+ if path == "" {
+ path = filepath.Join("fixtures", "state.json")
+ }
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("read fixture: %v", err)
+ }
+ var fx Fixture
+ if err := json.Unmarshal(raw, &fx); err != nil {
+ t.Fatalf("parse fixture: %v", err)
+ }
+ if override := os.Getenv("E2E_BASE_URL"); override != "" {
+ fx.BaseURL = override
+ }
+ pair, err := refreshTokenPair(fx.BaseURL, fx.RefreshToken)
+ if err != nil {
+ t.Fatalf("isolated refresh: %v", err)
+ }
+ fx.AccessToken = pair.AccessToken
+ fx.RefreshToken = pair.RefreshToken
+ return &Client{
+ BaseURL: fx.BaseURL,
+ HTTP: &http.Client{Timeout: 15 * time.Second},
+ Fixture: fx,
+ }
+}