add member totp
This commit is contained in:
parent
bdeb7e8263
commit
1f3eb3c992
|
|
@ -73,5 +73,7 @@ temp/
|
||||||
# =========================
|
# =========================
|
||||||
# Go workspace(本機多模組開發用)
|
# Go workspace(本機多模組開發用)
|
||||||
# =========================
|
# =========================
|
||||||
go.work
|
# E2E 產物(make e2e-full / e2e-up 生成)
|
||||||
go.work.sum
|
test/e2e/fixtures/state.json
|
||||||
|
test/e2e/fixtures/gateway.pid
|
||||||
|
.cache/
|
||||||
|
|
|
||||||
20
Makefile
20
Makefile
|
|
@ -15,7 +15,7 @@ GOLANGCI_PKG := github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
|
||||||
|
|
||||||
.DEFAULT_GOAL := help
|
.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
|
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: ## 顯示可用指令
|
help: ## 顯示可用指令
|
||||||
|
|
@ -58,6 +58,24 @@ gen-doc: build-go-doc ## 從 .api 生成 OpenAPI 3.0 YAML
|
||||||
test: ## 執行測試
|
test: ## 執行測試
|
||||||
$(GO) 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)
|
fmt: ## gofmt + goimports(不含 lint)
|
||||||
$(GOFMT) -s -w $(GOFILES)
|
$(GOFMT) -s -w $(GOFILES)
|
||||||
@command -v goimports >/dev/null 2>&1 && goimports -w . || (echo "goimports not found; run: make tools" && exit 1)
|
@command -v goimports >/dev/null 2>&1 && goimports -w . || (echo "goimports not found; run: make tools" && exit 1)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
```
|
||||||
|
|
@ -2,6 +2,8 @@ package member
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
memberdom "gateway/internal/model/member/domain"
|
memberdom "gateway/internal/model/member/domain"
|
||||||
|
|
@ -72,6 +74,10 @@ func startVerification(
|
||||||
}
|
}
|
||||||
return nil, sendErr
|
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{
|
return &types.VerificationStartData{
|
||||||
ChallengeID: dto.ChallengeID,
|
ChallengeID: dto.ChallengeID,
|
||||||
ExpiresIn: dto.ExpiresIn,
|
ExpiresIn: dto.ExpiresIn,
|
||||||
|
|
|
||||||
|
|
@ -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<br/>(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` |
|
||||||
|
|
@ -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<br/>(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<br/>(platform 註冊)
|
||||||
|
[*] --> active: Provisioning.Ensure*<br/>(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` |
|
||||||
|
|
@ -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<br/>SMTP / SES / Mock]
|
||||||
|
SMSChain[sms.Chain<br/>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 |
|
||||||
|
|
@ -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<br/>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 |
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
_ "embed"
|
_ "embed"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gateway/internal/model/permission/domain/entity"
|
"gateway/internal/model/permission/domain/entity"
|
||||||
|
|
@ -129,12 +130,12 @@ func Apply(
|
||||||
return report, nil
|
return report, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
idByName, err := loadCatalogIDIndex(ctx, perms)
|
allPerms, permByName, err := loadCatalogIndex(ctx, perms)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
for _, tenantID := range opts.TenantIDs {
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -225,23 +226,42 @@ func loadCatalogIDIndex(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
perms domrepo.PermissionRepository,
|
perms domrepo.PermissionRepository,
|
||||||
) (map[string]string, error) {
|
) (map[string]string, error) {
|
||||||
all, err := perms.GetAll(ctx, nil)
|
all, byName, err := loadCatalogIndex(ctx, perms)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
idByName := make(map[string]string, len(all))
|
idByName := make(map[string]string, len(all))
|
||||||
for _, p := range all {
|
for name, perm := range byName {
|
||||||
idByName[p.Name] = p.ID.Hex()
|
idByName[name] = perm.ID.Hex()
|
||||||
}
|
}
|
||||||
return idByName, nil
|
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(
|
func seedTenantRoles(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
roles domrepo.RoleRepository,
|
roles domrepo.RoleRepository,
|
||||||
rolePerms domrepo.RolePermissionRepository,
|
rolePerms domrepo.RolePermissionRepository,
|
||||||
tenantID string,
|
tenantID string,
|
||||||
idByName map[string]string,
|
allPerms []*entity.Permission,
|
||||||
|
permByName map[string]*entity.Permission,
|
||||||
report *Report,
|
report *Report,
|
||||||
) error {
|
) error {
|
||||||
for _, def := range DefaultSystemRoles {
|
for _, def := range DefaultSystemRoles {
|
||||||
|
|
@ -260,13 +280,9 @@ func seedTenantRoles(
|
||||||
}
|
}
|
||||||
report.RolesUpserted++
|
report.RolesUpserted++
|
||||||
}
|
}
|
||||||
permissionIDs := make([]string, 0, len(def.PermissionNames))
|
permissionIDs, err := resolveRolePermissionIDs(def, allPerms, permByName)
|
||||||
for _, name := range def.PermissionNames {
|
if err != nil {
|
||||||
id, ok := idByName[name]
|
return err
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("permission seed: catalog missing %q for role %s", name, def.Key)
|
|
||||||
}
|
|
||||||
permissionIDs = append(permissionIDs, id)
|
|
||||||
}
|
}
|
||||||
if err := rolePerms.SetForRole(ctx, tenantID, role.ID.Hex(), permissionIDs); err != nil {
|
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)
|
return fmt.Errorf("permission seed: tenant=%s set role perms %s: %w", tenantID, def.Key, err)
|
||||||
|
|
@ -275,3 +291,63 @@ func seedTenantRoles(
|
||||||
}
|
}
|
||||||
return nil
|
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]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)"
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue