修正 dev 登入與管理後台:ZITADEL Sessions 驗密、bootstrap admin、角色指派 UX。
ZITADEL v2 不支援 password grant,改優先走 Sessions API 以恢復 Email 登入; dev-up 自動 seed 權限與 admin@k6.local,並改善使用者角色頁在無角色時仍可指派。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
43c5a015ca
commit
b754a2d07d
12
Makefile
12
Makefile
|
|
@ -197,6 +197,7 @@ k6-wait: k6-wait-ldap ## 等 OpenLDAP + ZITADEL ready + bootstrap IdP/OAuth +
|
||||||
if [ -s "$(K6_BOOTSTRAP_ENV)" ]; then cat $(K6_BOOTSTRAP_ENV) >> $(K6_ENV_FILE); fi; \
|
if [ -s "$(K6_BOOTSTRAP_ENV)" ]; then cat $(K6_BOOTSTRAP_ENV) >> $(K6_ENV_FILE); fi; \
|
||||||
echo "wrote $(K6_ENV_FILE)"
|
echo "wrote $(K6_ENV_FILE)"
|
||||||
@$(MAKE) -s k6-seed-fixtures
|
@$(MAKE) -s k6-seed-fixtures
|
||||||
|
@$(MAKE) -s k6-seed-permissions
|
||||||
@echo "tip: 'source $(K6_ENV_FILE)' to load into your shell"
|
@echo "tip: 'source $(K6_ENV_FILE)' to load into your shell"
|
||||||
@echo "tip: run 'make dev-restart-gateway' if gateway was already running"
|
@echo "tip: run 'make dev-restart-gateway' if gateway was already running"
|
||||||
|
|
||||||
|
|
@ -226,6 +227,12 @@ print("tenant matched=" + t.matchedCount + " upserted=" + (t.upsertedId?1:0) + "
|
||||||
# Back-compat alias
|
# Back-compat alias
|
||||||
k6-seed-tenant: k6-seed-fixtures ## (alias for k6-seed-fixtures)
|
k6-seed-tenant: k6-seed-fixtures ## (alias for k6-seed-fixtures)
|
||||||
|
|
||||||
|
k6-seed-permissions: ## upsert permission catalog + system roles for k6-tenant
|
||||||
|
@mkdir -p bin
|
||||||
|
$(GO) build -o bin/permission-seed ./cmd/permission-seed
|
||||||
|
@./bin/permission-seed -tenants $(K6_TENANT_ID)
|
||||||
|
@echo "seeded permissions + system roles for $(K6_TENANT_ID)"
|
||||||
|
|
||||||
k6-build: ## 建 gateway binary 給 k6 使用
|
k6-build: ## 建 gateway binary 給 k6 使用
|
||||||
@mkdir -p $(dir $(K6_GATEWAY_BIN))
|
@mkdir -p $(dir $(K6_GATEWAY_BIN))
|
||||||
$(GO) build -o $(K6_GATEWAY_BIN) ./gateway.go
|
$(GO) build -o $(K6_GATEWAY_BIN) ./gateway.go
|
||||||
|
|
@ -276,7 +283,7 @@ DEV_GATEWAY_LOG := $(DEV_DIR)/gateway.log
|
||||||
|
|
||||||
.PHONY: dev-up dev-down dev-status dev-restart-gateway
|
.PHONY: dev-up dev-down dev-status dev-restart-gateway
|
||||||
|
|
||||||
dev-up: k6-up k6-wait k6-build dev-restart-gateway ## 一鍵起全套:mongo/redis/mailhog/zitadel + seed + Gateway 背景
|
dev-up: k6-up k6-wait k6-build dev-restart-gateway dev-seed-admin ## 一鍵起全套:mongo/redis/mailhog/zitadel + seed + Gateway + bootstrap admin
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "=========================================="
|
@echo "=========================================="
|
||||||
@echo " 本機測試環境已就緒"
|
@echo " 本機測試環境已就緒"
|
||||||
|
|
@ -287,9 +294,10 @@ dev-up: k6-up k6-wait k6-build dev-restart-gateway ## 一鍵起全套:mongo/re
|
||||||
@echo " ZITADEL 主控台 http://localhost:8080/ui/console"
|
@echo " ZITADEL 主控台 http://localhost:8080/ui/console"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo " 註冊預設:租戶 k6-tenant · 邀請碼 K6INVITE"
|
@echo " 註冊預設:租戶 k6-tenant · 邀請碼 K6INVITE"
|
||||||
|
@echo " 原始管理員:admin@k6.local / Admin-Pass-1!(dev-up 已自動建立)"
|
||||||
@echo " LDAP 登入:alice / Password1!(make k6-wait 已自動設定 ZITADEL IdP)"
|
@echo " LDAP 登入:alice / Password1!(make k6-wait 已自動設定 ZITADEL IdP)"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo " 管理後台:make dev-seed-admin 後用輸出的帳密登入"
|
@echo " 管理後台:用 admin@k6.local 登入 → 頂部「管理後台」→ 角色 / 權限"
|
||||||
@echo " 關閉環境:make dev-down"
|
@echo " 關閉環境:make dev-down"
|
||||||
@echo " 查看狀態:make dev-status"
|
@echo " 查看狀態:make dev-status"
|
||||||
@echo " OAuth/LDAP 設定變更後:make dev-restart-gateway"
|
@echo " OAuth/LDAP 設定變更後:make dev-restart-gateway"
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,8 @@
|
||||||
// 6. Print ADMIN_EMAIL / ADMIN_PASSWORD / ADMIN_UID env exports to stdout so
|
// 6. Print ADMIN_EMAIL / ADMIN_PASSWORD / ADMIN_UID env exports to stdout so
|
||||||
// callers can `eval "$(make k6-seed-admin ...)"` or redirect into a file.
|
// callers can `eval "$(make k6-seed-admin ...)"` or redirect into a file.
|
||||||
//
|
//
|
||||||
// Re-running is safe: register is idempotent at the OTP-confirm step (the
|
// Re-running is safe: register skips when email exists, and seed.Apply /
|
||||||
// challenge is fresh per call), and seed.Apply / UserRole insert are
|
// UserRole insert are idempotent-by-key.
|
||||||
// idempotent-by-key.
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
@ -29,6 +28,8 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
libmongo "gateway/internal/library/mongo"
|
libmongo "gateway/internal/library/mongo"
|
||||||
|
memberentity "gateway/internal/model/member/domain/entity"
|
||||||
|
memberenum "gateway/internal/model/member/domain/enum"
|
||||||
memberrepo "gateway/internal/model/member/repository"
|
memberrepo "gateway/internal/model/member/repository"
|
||||||
permdomain "gateway/internal/model/permission/domain"
|
permdomain "gateway/internal/model/permission/domain"
|
||||||
permentity "gateway/internal/model/permission/domain/entity"
|
permentity "gateway/internal/model/permission/domain/entity"
|
||||||
|
|
@ -40,17 +41,22 @@ import (
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
authAlreadyRegisteredCode = 28303000
|
||||||
|
authInvalidCredentialsCode = 28501000
|
||||||
|
legacyAdminPassword = "K6-Admin-Pass-1!"
|
||||||
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
flagBase = flag.String("base", envOr("BASE_URL", "http://localhost:8888"), "Gateway base URL")
|
flagBase = flag.String("base", envOr("BASE_URL", "http://localhost:8888"), "Gateway base URL")
|
||||||
flagMailhog = flag.String("mailhog", envOr("MAILHOG_URL", "http://localhost:8025"), "MailHog HTTP API URL")
|
flagMailhog = flag.String("mailhog", envOr("MAILHOG_URL", "http://localhost:8025"), "MailHog HTTP API URL")
|
||||||
flagTenant = flag.String("tenant", envOr("TENANT_SLUG", "k6-tenant"), "Tenant slug")
|
flagTenant = flag.String("tenant", envOr("TENANT_SLUG", "k6-tenant"), "Tenant slug")
|
||||||
flagInvite = flag.String("invite", envOr("INVITE_CODE", "K6INVITE"), "Invite code")
|
flagInvite = flag.String("invite", envOr("INVITE_CODE", "K6INVITE"), "Invite code")
|
||||||
// Default email is rotated per-invocation. Re-running seed-admin against
|
// Fixed bootstrap admin for dev / frontend. Re-run is idempotent: if the
|
||||||
// a stable email would collide with the existing ZITADEL user (28303000
|
// email is already registered we look up the UID in Mongo and ensure
|
||||||
// email already registered) since ZITADEL state lives outside docker
|
// tenant_admin is assigned. Override with -email or ADMIN_EMAIL.
|
||||||
// volumes that `make k6-down` clears. Override with -email or ADMIN_EMAIL.
|
flagEmail = flag.String("email", envOr("ADMIN_EMAIL", "admin@k6.local"), "Admin email")
|
||||||
flagEmail = flag.String("email", envOr("ADMIN_EMAIL", fmt.Sprintf("k6-admin-%d@k6.local", time.Now().Unix())), "Admin email")
|
flagPassword = flag.String("password", envOr("ADMIN_PASSWORD", "Admin-Pass-1!"), "Admin password")
|
||||||
flagPassword = flag.String("password", envOr("ADMIN_PASSWORD", "K6-Admin-Pass-1!"), "Admin password")
|
|
||||||
flagMongoHost = flag.String("mongo-host", envOr("K6_MONGO_HOST", "127.0.0.1"), "Mongo host")
|
flagMongoHost = flag.String("mongo-host", envOr("K6_MONGO_HOST", "127.0.0.1"), "Mongo host")
|
||||||
flagMongoPort = flag.Int("mongo-port", envOrInt("K6_MONGO_PORT", 27017), "Mongo port")
|
flagMongoPort = flag.Int("mongo-port", envOrInt("K6_MONGO_PORT", 27017), "Mongo port")
|
||||||
flagMongoDB = flag.String("mongo-db", envOr("K6_MONGO_DB", "gateway_k6"), "Mongo database")
|
flagMongoDB = flag.String("mongo-db", envOr("K6_MONGO_DB", "gateway_k6"), "Mongo database")
|
||||||
|
|
@ -71,30 +77,6 @@ func main() {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
logf("registering admin %s @ %s", *flagEmail, *flagBase)
|
|
||||||
regResp, err := register(ctx)
|
|
||||||
if err != nil {
|
|
||||||
exitf("register: %v", err)
|
|
||||||
}
|
|
||||||
logf("challenge_id=%s uid=%s", regResp.ChallengeID, regResp.UID)
|
|
||||||
|
|
||||||
code, err := pollOTP(ctx, *flagEmail, time.Duration(*flagPollSecs)*time.Second)
|
|
||||||
if err != nil {
|
|
||||||
exitf("poll OTP: %v", err)
|
|
||||||
}
|
|
||||||
logf("OTP=%s", code)
|
|
||||||
|
|
||||||
tokens, err := confirm(ctx, regResp.ChallengeID, code)
|
|
||||||
if err != nil {
|
|
||||||
exitf("register/confirm: %v", err)
|
|
||||||
}
|
|
||||||
logf("registration confirmed; admin uid=%s access_token=%d chars", regResp.UID, len(tokens.AccessToken))
|
|
||||||
|
|
||||||
if *flagDryRun {
|
|
||||||
writeOutput(*flagEmail, *flagPassword, regResp.UID, "", tokens)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
mongoConf := &libmongo.Conf{
|
mongoConf := &libmongo.Conf{
|
||||||
Schema: "mongodb",
|
Schema: "mongodb",
|
||||||
Host: *flagMongoHost,
|
Host: *flagMongoHost,
|
||||||
|
|
@ -112,10 +94,25 @@ func main() {
|
||||||
}
|
}
|
||||||
logf("tenant_id=%s", tenantID)
|
logf("tenant_id=%s", tenantID)
|
||||||
|
|
||||||
|
if !*flagDryRun {
|
||||||
if err := seedRoles(ctx, mongoConf, tenantID); err != nil {
|
if err := seedRoles(ctx, mongoConf, tenantID); err != nil {
|
||||||
exitf("seed roles: %v", err)
|
exitf("seed roles: %v", err)
|
||||||
}
|
}
|
||||||
roleID, err := assignAdmin(ctx, mongoConf, tenantID, regResp.UID)
|
}
|
||||||
|
|
||||||
|
logf("ensuring bootstrap admin %s @ %s", *flagEmail, *flagBase)
|
||||||
|
adminUID, tokens, err := ensureAdminUser(ctx, mongoConf, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
exitf("ensure admin: %v", err)
|
||||||
|
}
|
||||||
|
logf("bootstrap admin uid=%s", adminUID)
|
||||||
|
|
||||||
|
if *flagDryRun {
|
||||||
|
writeOutput(*flagEmail, *flagPassword, adminUID, tenantID, tokens)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
roleID, err := assignAdmin(ctx, mongoConf, tenantID, adminUID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exitf("assign tenant_admin: %v", err)
|
exitf("assign tenant_admin: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -135,7 +132,141 @@ func main() {
|
||||||
// before callers (e.g. make k6-journey) hit /roles.
|
// before callers (e.g. make k6-journey) hit /roles.
|
||||||
time.Sleep(500 * time.Millisecond)
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
|
||||||
writeOutput(*flagEmail, *flagPassword, regResp.UID, tenantID, tokens)
|
writeOutput(*flagEmail, *flagPassword, adminUID, tenantID, tokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureAdminUser registers the bootstrap admin or reuses an existing member
|
||||||
|
// when the email is already taken. Mongo is checked first so re-runs do not
|
||||||
|
// depend on ZITADEL password matching -password.
|
||||||
|
func ensureAdminUser(
|
||||||
|
ctx context.Context,
|
||||||
|
conf *libmongo.Conf,
|
||||||
|
tenantID string,
|
||||||
|
) (uid string, tokens *confirmResp, err error) {
|
||||||
|
if member, lookupErr := lookupMemberByEmail(ctx, conf, tenantID, *flagEmail); lookupErr == nil {
|
||||||
|
logf("existing member uid=%s status=%s", member.UID, member.Status)
|
||||||
|
switch member.Status {
|
||||||
|
case memberenum.MemberStatusActive:
|
||||||
|
return member.UID, nil, nil
|
||||||
|
case memberenum.MemberStatusUnverified:
|
||||||
|
return finishUnverifiedRegistration(ctx, member.UID)
|
||||||
|
default:
|
||||||
|
return "", nil, fmt.Errorf("member uid=%s status=%s cannot be bootstrapped", member.UID, member.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uid, tokens, err = registerAndConfirm(ctx, *flagPassword)
|
||||||
|
if err == nil {
|
||||||
|
return uid, tokens, nil
|
||||||
|
}
|
||||||
|
if isPasswordMismatchErr(err) && *flagPassword != legacyAdminPassword {
|
||||||
|
logf("retry register with legacy bootstrap password")
|
||||||
|
uid, tokens, retryErr := registerAndConfirm(ctx, legacyAdminPassword)
|
||||||
|
if retryErr == nil {
|
||||||
|
logf("warn: admin was created with legacy password %q; set ADMIN_PASSWORD=%q to login",
|
||||||
|
legacyAdminPassword, legacyAdminPassword)
|
||||||
|
return uid, tokens, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !isRecoverableRegisterErr(err) {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
member, lookupErr := lookupMemberByEmail(ctx, conf, tenantID, *flagEmail)
|
||||||
|
if lookupErr != nil {
|
||||||
|
return "", nil, fmt.Errorf(
|
||||||
|
"admin email exists in ZITADEL but no member in Mongo — set ADMIN_PASSWORD to the password from the first seed, or run make k6-down: %w",
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isPasswordMismatchErr(err) {
|
||||||
|
logf("warn: ZITADEL password differs from -password; continuing role assign for uid=%s", member.UID)
|
||||||
|
}
|
||||||
|
logf("reusing member uid=%s status=%s after register conflict", member.UID, member.Status)
|
||||||
|
|
||||||
|
switch member.Status {
|
||||||
|
case memberenum.MemberStatusActive:
|
||||||
|
return member.UID, nil, nil
|
||||||
|
case memberenum.MemberStatusUnverified:
|
||||||
|
return finishUnverifiedRegistration(ctx, member.UID)
|
||||||
|
default:
|
||||||
|
return "", nil, fmt.Errorf("member uid=%s status=%s cannot be bootstrapped", member.UID, member.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerAndConfirm(ctx context.Context, password string) (uid string, tokens *confirmResp, err error) {
|
||||||
|
regResp, err := register(ctx, password)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
logf("challenge_id=%s uid=%s", regResp.ChallengeID, regResp.UID)
|
||||||
|
|
||||||
|
code, err := pollOTP(ctx, *flagEmail, time.Duration(*flagPollSecs)*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
logf("OTP=%s", code)
|
||||||
|
|
||||||
|
tokens, err = confirm(ctx, regResp.ChallengeID, code)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
logf("registration confirmed; access_token=%d chars", len(tokens.AccessToken))
|
||||||
|
return regResp.UID, tokens, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func finishUnverifiedRegistration(ctx context.Context, uid string) (string, *confirmResp, error) {
|
||||||
|
regResp, err := resumeRegister(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, fmt.Errorf("resume unverified registration uid=%s: %w", uid, err)
|
||||||
|
}
|
||||||
|
logf("resumed registration challenge_id=%s uid=%s", regResp.ChallengeID, regResp.UID)
|
||||||
|
|
||||||
|
code, err := pollOTP(ctx, *flagEmail, time.Duration(*flagPollSecs)*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
logf("OTP=%s", code)
|
||||||
|
|
||||||
|
tokens, err := confirm(ctx, regResp.ChallengeID, code)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
logf("registration confirmed; access_token=%d chars", len(tokens.AccessToken))
|
||||||
|
if regResp.UID != "" {
|
||||||
|
uid = regResp.UID
|
||||||
|
}
|
||||||
|
return uid, tokens, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isRecoverableRegisterErr(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
msg := err.Error()
|
||||||
|
return strings.Contains(msg, fmt.Sprintf("code=%d", authAlreadyRegisteredCode)) ||
|
||||||
|
strings.Contains(msg, fmt.Sprintf("code=%d", authInvalidCredentialsCode)) ||
|
||||||
|
strings.Contains(msg, "already registered") ||
|
||||||
|
strings.Contains(msg, "invalid credentials")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPasswordMismatchErr(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
msg := err.Error()
|
||||||
|
return strings.Contains(msg, fmt.Sprintf("code=%d", authInvalidCredentialsCode)) ||
|
||||||
|
strings.Contains(msg, "invalid credentials")
|
||||||
|
}
|
||||||
|
|
||||||
|
func lookupMemberByEmail(ctx context.Context, conf *libmongo.Conf, tenantID, email string) (*memberentity.Member, error) {
|
||||||
|
repo := memberrepo.NewMemberRepository(memberrepo.MemberRepositoryParam{Conf: conf})
|
||||||
|
member, err := repo.GetByZitadelEmail(ctx, tenantID, strings.ToLower(strings.TrimSpace(email)))
|
||||||
|
if err != nil || member == nil || member.UID == "" {
|
||||||
|
return nil, fmt.Errorf("member not found for email=%s tenant=%s: %w", email, tenantID, err)
|
||||||
|
}
|
||||||
|
return member, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// broadcastReload publishes a casbin reload event on the same Redis channel
|
// broadcastReload publishes a casbin reload event on the same Redis channel
|
||||||
|
|
@ -184,13 +315,13 @@ type envelope struct {
|
||||||
Data json.RawMessage `json:"data"`
|
Data json.RawMessage `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func register(ctx context.Context) (*registerResp, error) {
|
func register(ctx context.Context, password string) (*registerResp, error) {
|
||||||
body, _ := json.Marshal(map[string]any{
|
body, _ := json.Marshal(map[string]any{
|
||||||
"tenant_slug": *flagTenant,
|
"tenant_slug": *flagTenant,
|
||||||
"invite_code": *flagInvite,
|
"invite_code": *flagInvite,
|
||||||
"email": *flagEmail,
|
"email": *flagEmail,
|
||||||
"password": *flagPassword,
|
"password": password,
|
||||||
"display_name": "k6 admin",
|
"display_name": "Bootstrap Admin",
|
||||||
"language": "zh-TW",
|
"language": "zh-TW",
|
||||||
"accept_terms_version": "2025-01-01",
|
"accept_terms_version": "2025-01-01",
|
||||||
"marketing_opt_in": false,
|
"marketing_opt_in": false,
|
||||||
|
|
@ -206,6 +337,22 @@ func register(ctx context.Context) (*registerResp, error) {
|
||||||
return &r, nil
|
return &r, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resumeRegister(ctx context.Context) (*registerResp, error) {
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"tenant_slug": *flagTenant,
|
||||||
|
"email": *flagEmail,
|
||||||
|
})
|
||||||
|
env, err := doJSON(ctx, "POST", *flagBase+"/api/v1/auth/register/resume", body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var r registerResp
|
||||||
|
if err := json.Unmarshal(env.Data, &r); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode register resume data: %w", err)
|
||||||
|
}
|
||||||
|
return &r, nil
|
||||||
|
}
|
||||||
|
|
||||||
func confirm(ctx context.Context, challengeID, code string) (*confirmResp, error) {
|
func confirm(ctx context.Context, challengeID, code string) (*confirmResp, error) {
|
||||||
body, _ := json.Marshal(map[string]any{
|
body, _ := json.Marshal(map[string]any{
|
||||||
"tenant_slug": *flagTenant,
|
"tenant_slug": *flagTenant,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
// permission-seed upserts the platform permission catalog and default system
|
||||||
|
// roles for one or more tenants. Safe to re-run (idempotent by key).
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
libmongo "gateway/internal/library/mongo"
|
||||||
|
permrepo "gateway/internal/model/permission/repository"
|
||||||
|
permseed "gateway/internal/model/permission/seed"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/core/logx"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
flagMongoHost = flag.String("mongo-host", envOr("K6_MONGO_HOST", "127.0.0.1"), "Mongo host")
|
||||||
|
flagMongoPort = flag.Int("mongo-port", envOrInt("K6_MONGO_PORT", 27017), "Mongo port")
|
||||||
|
flagMongoDB = flag.String("mongo-db", envOr("K6_MONGO_DB", "gateway_k6"), "Mongo database")
|
||||||
|
flagTenants = flag.String("tenants", envOr("PERM_SEED_TENANTS", "k6-tenant"), "Comma-separated tenant_id list")
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
flag.Parse()
|
||||||
|
logx.Disable()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
tenantIDs := splitCSV(*flagTenants)
|
||||||
|
if len(tenantIDs) == 0 {
|
||||||
|
exitf("no tenants to seed")
|
||||||
|
}
|
||||||
|
|
||||||
|
conf := &libmongo.Conf{
|
||||||
|
Schema: "mongodb",
|
||||||
|
Host: *flagMongoHost,
|
||||||
|
Port: *flagMongoPort,
|
||||||
|
Database: *flagMongoDB,
|
||||||
|
}
|
||||||
|
|
||||||
|
perms := permrepo.NewPermissionRepository(permrepo.PermissionRepositoryParam{Conf: conf})
|
||||||
|
roles := permrepo.NewRoleRepository(permrepo.RoleRepositoryParam{Conf: conf})
|
||||||
|
rolePerms := permrepo.NewRolePermissionRepository(permrepo.RolePermissionRepositoryParam{Conf: conf})
|
||||||
|
|
||||||
|
rpt, err := permseed.Apply(ctx, perms, roles, rolePerms, permseed.ApplyOptions{
|
||||||
|
TenantIDs: tenantIDs,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
exitf("seed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logf("tenants=%v catalog=%d roles=%d role_perms=%d",
|
||||||
|
tenantIDs, rpt.CatalogUpserted, rpt.RolesUpserted, rpt.RolePermissionSet)
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitCSV(s string) []string {
|
||||||
|
parts := strings.Split(s, ",")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p != "" {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOr(k, def string) string {
|
||||||
|
if v := os.Getenv(k); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOrInt(k string, def int) int {
|
||||||
|
if v := os.Getenv(k); v != "" {
|
||||||
|
var n int
|
||||||
|
if _, err := fmt.Sscanf(v, "%d", &n); err == nil {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
func logf(format string, a ...any) {
|
||||||
|
fmt.Fprintf(os.Stderr, "[permission-seed] "+format+"\n", a...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func exitf(format string, a ...any) {
|
||||||
|
logf(format, a...)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
@ -107,8 +107,8 @@ Zitadel:
|
||||||
APIBase: http://localhost:8080
|
APIBase: http://localhost:8080
|
||||||
ServiceUserToken: ""
|
ServiceUserToken: ""
|
||||||
DefaultOrgID: ""
|
DefaultOrgID: ""
|
||||||
OAuthClientID: "374875801008562439"
|
OAuthClientID: ""
|
||||||
OAuthClientSecret: "Z1SUCIsozer52x2DNhKHXcTZROicf2lFFLLr4pkTZjLXHfkunTzjKYmMk2EHKDch"
|
OAuthClientSecret: ""
|
||||||
GoogleClientID: ""
|
GoogleClientID: ""
|
||||||
GoogleClientSecret: ""
|
GoogleClientSecret: ""
|
||||||
GoogleIdPID: ""
|
GoogleIdPID: ""
|
||||||
|
|
|
||||||
|
|
@ -52,13 +52,22 @@ make frontend-dev
|
||||||
|
|
||||||
### 取得管理員權限
|
### 取得管理員權限
|
||||||
|
|
||||||
```bash
|
`make dev-up` 會自動建立原始管理員並寫入 Mongo:
|
||||||
make k6-seed-admin
|
|
||||||
# 將輸出的 ADMIN_ACCESS_TOKEN 貼到瀏覽器 — 需自行在 localStorage 設 access_token
|
|
||||||
# 或:用 seed 產生的帳密在 /login 登入(若 ZITADEL password grant 可用)
|
|
||||||
```
|
|
||||||
|
|
||||||
較簡單做法:完成一般註冊登入後,在 Mongo 手動指派 `tenant_admin`,或跑 `k6-seed-admin` 用新帳號登入。
|
| 欄位 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| Email | `admin@k6.local` |
|
||||||
|
| 密碼 | `Admin-Pass-1!` |
|
||||||
|
| 租戶 | `k6-tenant` |
|
||||||
|
| 角色 | `tenant_admin` |
|
||||||
|
|
||||||
|
登入後頂部會出現「管理後台」,或在首頁 `/app` 的「權限管理」卡片進入。
|
||||||
|
|
||||||
|
手動重跑 seed(冪等):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make dev-seed-admin
|
||||||
|
```
|
||||||
|
|
||||||
## 環境變數
|
## 環境變數
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,8 @@ export interface UserRoleList {
|
||||||
user_roles: Array<{
|
user_roles: Array<{
|
||||||
role_id: string;
|
role_id: string;
|
||||||
role_key: string;
|
role_key: string;
|
||||||
display_name: string;
|
role_display_name?: string;
|
||||||
|
display_name?: string;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,30 @@ import type { Role } from '../../api/permission';
|
||||||
import { ApiError } from '../../api/http';
|
import { ApiError } from '../../api/http';
|
||||||
import { useAuth } from '../../context/AuthContext';
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
|
||||||
|
const UID_PREFIX = 'K6';
|
||||||
|
|
||||||
|
/** 補齊 UID:10000002 → K6-10000002;已是 K6-10000002 則原樣 */
|
||||||
|
function normalizeUid(raw: string): string {
|
||||||
|
const s = raw.trim().toUpperCase();
|
||||||
|
if (/^[A-Z]{2,4}-\d+$/.test(s)) return s;
|
||||||
|
if (/^\d+$/.test(s)) return `${UID_PREFIX}-${s}`;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapUserRoles(
|
||||||
|
rows: permApi.UserRoleList['user_roles'] | undefined,
|
||||||
|
): Array<{ role_id: string; role_key: string; display_name: string }> {
|
||||||
|
return (rows ?? []).map((ur) => ({
|
||||||
|
role_id: ur.role_id,
|
||||||
|
role_key: ur.role_key,
|
||||||
|
display_name: ur.role_display_name ?? ur.display_name ?? ur.role_key,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
export function UserRolesPage() {
|
export function UserRolesPage() {
|
||||||
const { uid: myUid } = useAuth();
|
const { uid: myUid } = useAuth();
|
||||||
const [uid, setUid] = useState('');
|
const [uid, setUid] = useState('');
|
||||||
|
const [searchedUid, setSearchedUid] = useState('');
|
||||||
const [roles, setRoles] = useState<Role[]>([]);
|
const [roles, setRoles] = useState<Role[]>([]);
|
||||||
const [userRoles, setUserRoles] = useState<
|
const [userRoles, setUserRoles] = useState<
|
||||||
Array<{ role_id: string; role_key: string; display_name: string }>
|
Array<{ role_id: string; role_key: string; display_name: string }>
|
||||||
|
|
@ -19,28 +40,42 @@ export function UserRolesPage() {
|
||||||
permApi.listRoles().then((r) => setRoles(r.roles ?? []));
|
permApi.listRoles().then((r) => setRoles(r.roles ?? []));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const queryUid = (raw: string) => normalizeUid(raw);
|
||||||
|
|
||||||
const search = async (e: FormEvent) => {
|
const search = async (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError('');
|
setError('');
|
||||||
setMsg('');
|
setMsg('');
|
||||||
|
setSearchedUid('');
|
||||||
loadRoleOptions();
|
loadRoleOptions();
|
||||||
|
const targetUid = queryUid(uid);
|
||||||
|
if (targetUid !== uid.trim()) {
|
||||||
|
setUid(targetUid);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const r = await permApi.listUserRoles(uid.trim());
|
const r = await permApi.listUserRoles(targetUid);
|
||||||
setUserRoles(r.user_roles ?? []);
|
setUserRoles(mapUserRoles(r.user_roles));
|
||||||
setMsg('已載入');
|
setSearchedUid(targetUid);
|
||||||
|
setMsg(
|
||||||
|
(r.user_roles ?? []).length > 0
|
||||||
|
? '已載入'
|
||||||
|
: '查詢成功:此使用者目前尚無角色,可直接下方指派',
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof ApiError ? e.message : '查詢失敗');
|
setError(e instanceof ApiError ? e.message : '查詢失敗');
|
||||||
setUserRoles([]);
|
setUserRoles([]);
|
||||||
|
setSearchedUid('');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const assign = async () => {
|
const assign = async () => {
|
||||||
if (!assignRoleId) return;
|
if (!assignRoleId || !searchedUid) return;
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
await permApi.assignUserRole(uid.trim(), assignRoleId);
|
await permApi.assignUserRole(searchedUid, assignRoleId);
|
||||||
const r = await permApi.listUserRoles(uid.trim());
|
const r = await permApi.listUserRoles(searchedUid);
|
||||||
setUserRoles(r.user_roles ?? []);
|
setUserRoles(mapUserRoles(r.user_roles));
|
||||||
|
setAssignRoleId('');
|
||||||
setMsg('已指派角色');
|
setMsg('已指派角色');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof ApiError ? e.message : '指派失敗');
|
setError(e instanceof ApiError ? e.message : '指派失敗');
|
||||||
|
|
@ -48,12 +83,12 @@ export function UserRolesPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const revoke = async (roleId: string) => {
|
const revoke = async (roleId: string) => {
|
||||||
if (!confirm('撤銷此角色?')) return;
|
if (!confirm('撤銷此角色?') || !searchedUid) return;
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
await permApi.revokeUserRole(uid.trim(), roleId);
|
await permApi.revokeUserRole(searchedUid, roleId);
|
||||||
const r = await permApi.listUserRoles(uid.trim());
|
const r = await permApi.listUserRoles(searchedUid);
|
||||||
setUserRoles(r.user_roles ?? []);
|
setUserRoles(mapUserRoles(r.user_roles));
|
||||||
setMsg('已撤銷');
|
setMsg('已撤銷');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof ApiError ? e.message : '撤銷失敗');
|
setError(e instanceof ApiError ? e.message : '撤銷失敗');
|
||||||
|
|
@ -64,13 +99,14 @@ export function UserRolesPage() {
|
||||||
<div>
|
<div>
|
||||||
<h1>使用者角色</h1>
|
<h1>使用者角色</h1>
|
||||||
<p className="hint">
|
<p className="hint">
|
||||||
輸入成員 UID(註冊後可在首頁查看)。指派 <code>tenant_admin</code>{' '}
|
請輸入<strong>完整 UID</strong>(例:<code>K6-10000002</code>,可在對方首頁
|
||||||
或 <code>tenant_owner</code> 後,對方重新登入即可進管理後台。
|
/app 查看)。只輸入數字如 <code>10000002</code> 也會自動補上前綴。指派{' '}
|
||||||
|
<code>tenant_admin</code> 或 <code>tenant_owner</code> 後,對方重新登入即可進管理後台。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form onSubmit={search} className="form form-inline-block">
|
<form onSubmit={search} className="form form-inline-block">
|
||||||
<input
|
<input
|
||||||
placeholder="使用者 UID(例:K6-10000001)"
|
placeholder="使用者 UID(例:K6-10000002 或 10000002)"
|
||||||
value={uid}
|
value={uid}
|
||||||
onChange={(e) => setUid(e.target.value)}
|
onChange={(e) => setUid(e.target.value)}
|
||||||
required
|
required
|
||||||
|
|
@ -92,9 +128,15 @@ export function UserRolesPage() {
|
||||||
{msg && <p className="form-ok">{msg}</p>}
|
{msg && <p className="form-ok">{msg}</p>}
|
||||||
{error && <p className="form-error">{error}</p>}
|
{error && <p className="form-error">{error}</p>}
|
||||||
|
|
||||||
{userRoles.length > 0 && (
|
{searchedUid && (
|
||||||
<>
|
<>
|
||||||
|
<p className="hint">
|
||||||
|
查詢目標:<code>{searchedUid}</code>
|
||||||
|
</p>
|
||||||
<h2>目前角色</h2>
|
<h2>目前角色</h2>
|
||||||
|
{userRoles.length === 0 ? (
|
||||||
|
<p className="muted">尚無指派角色</p>
|
||||||
|
) : (
|
||||||
<ul className="role-assign-list">
|
<ul className="role-assign-list">
|
||||||
{userRoles.map((ur) => (
|
{userRoles.map((ur) => (
|
||||||
<li key={ur.role_id}>
|
<li key={ur.role_id}>
|
||||||
|
|
@ -111,6 +153,7 @@ export function UserRolesPage() {
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
<h2>指派新角色</h2>
|
<h2>指派新角色</h2>
|
||||||
<div className="form-inline-block">
|
<div className="form-inline-block">
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import * as memberApi from '../../api/member';
|
import * as memberApi from '../../api/member';
|
||||||
import { ApiError } from '../../api/http';
|
import { ApiError } from '../../api/http';
|
||||||
import { useAuth } from '../../context/AuthContext';
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
|
||||||
export function HomePage() {
|
export function HomePage() {
|
||||||
const { roles, uid } = useAuth();
|
const { roles, uid, isAdmin } = useAuth();
|
||||||
const [me, setMe] = useState<memberApi.MemberMe | null>(null);
|
const [me, setMe] = useState<memberApi.MemberMe | null>(null);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
|
@ -64,6 +65,23 @@ export function HomePage() {
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="card">
|
||||||
|
<h3>權限管理</h3>
|
||||||
|
<p className="hint">您具備管理員身份,可調整角色與權限。</p>
|
||||||
|
<ul className="admin-links">
|
||||||
|
<li>
|
||||||
|
<Link to="/admin/roles">角色管理</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link to="/admin/role-permissions">角色權限設定</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link to="/admin/users">使用者角色指派</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -199,16 +199,20 @@ type TokenResult struct {
|
||||||
Email string
|
Email string
|
||||||
}
|
}
|
||||||
|
|
||||||
// VerifyPassword checks email/password credentials. Uses OAuth2 ROPG when OAuthClientID
|
// VerifyPassword checks email/password credentials. ZITADEL v2 disables the
|
||||||
// and OAuthClientSecret are configured; otherwise uses ZITADEL v2 Sessions API (PAT).
|
// resource-owner password grant by default, so when a service PAT is configured
|
||||||
|
// we use the Sessions API first. ROPG remains a fallback for legacy instances.
|
||||||
func (c *Client) VerifyPassword(ctx context.Context, username, password string) (*TokenResult, error) {
|
func (c *Client) VerifyPassword(ctx context.Context, username, password string) (*TokenResult, error) {
|
||||||
if c == nil {
|
if c == nil {
|
||||||
return nil, ErrNotConfigured
|
return nil, ErrNotConfigured
|
||||||
}
|
}
|
||||||
|
if c.conf.ServiceUserToken != "" {
|
||||||
|
return c.verifyPasswordSession(ctx, username, password)
|
||||||
|
}
|
||||||
if c.conf.OAuthClientID != "" && c.conf.OAuthClientSecret != "" {
|
if c.conf.OAuthClientID != "" && c.conf.OAuthClientSecret != "" {
|
||||||
return c.verifyPasswordROPG(ctx, username, password)
|
return c.verifyPasswordROPG(ctx, username, password)
|
||||||
}
|
}
|
||||||
return c.verifyPasswordSession(ctx, username, password)
|
return nil, fmt.Errorf("zitadel: password verification not configured (need service token or oauth client)")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) verifyPasswordROPG(ctx context.Context, username, password string) (*TokenResult, error) {
|
func (c *Client) verifyPasswordROPG(ctx context.Context, username, password string) (*TokenResult, error) {
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,6 @@ func TestVerifyPassword(t *testing.T) {
|
||||||
|
|
||||||
c, err := zitadel.NewClient(zitadel.Conf{
|
c, err := zitadel.NewClient(zitadel.Conf{
|
||||||
Issuer: srv.URL,
|
Issuer: srv.URL,
|
||||||
ServiceUserToken: testPAT,
|
|
||||||
OAuthClientID: testClientID,
|
OAuthClientID: testClientID,
|
||||||
OAuthClientSecret: testSecret,
|
OAuthClientSecret: testSecret,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue