feat: bootstrap console administrator
This commit is contained in:
@@ -5,11 +5,13 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// BootstrapInput 仅供持有数据库管理权限的初始化命令使用,不暴露为公共 HTTP。
|
||||
@@ -30,16 +32,20 @@ type BootstrapInput struct {
|
||||
ScheduleAccess bool // ScheduleAccess 显式增加开发用计划查询及管理权限。
|
||||
ResourceAccess bool // ResourceAccess 显式授予整个应用的额度查询及管理权限。
|
||||
ExistingApplicationID core.ID // ExistingApplicationID 在指定现有应用下创建命名空间,与 Application 二选一。
|
||||
ConsoleUsername string // ConsoleUsername 可选的首个控制台管理员用户名;与 ConsolePassword 成对使用。
|
||||
ConsoleDisplayName string // ConsoleDisplayName 可选的控制台展示名称,空时使用用户名。
|
||||
ConsolePassword string // ConsolePassword 仅在初始化事务内短暂使用,数据库只保存 bcrypt 哈希。
|
||||
}
|
||||
|
||||
// BootstrapResult 包含一次性交付的原始凭据,禁止记录到日志或提交到版本库。
|
||||
type BootstrapResult struct {
|
||||
ApplicationID core.ID `json:"application_id"` // ApplicationID 创建或关联的应用标识。
|
||||
NamespaceID core.ID `json:"namespace_id"` // NamespaceID 新建隔离范围。
|
||||
QueueID core.ID `json:"queue_id"` // QueueID 初始队列标识。
|
||||
CredentialID core.ID `json:"credential_id"` // CredentialID 凭据记录标识。
|
||||
Token string `json:"token"` // Token 原始秘密,仅保存到受保护交付文件。
|
||||
ExpiresAt time.Time `json:"expires_at"` // ExpiresAt 初始凭据到期时间,UTC。
|
||||
ApplicationID core.ID `json:"application_id"` // ApplicationID 创建或关联的应用标识。
|
||||
NamespaceID core.ID `json:"namespace_id"` // NamespaceID 新建隔离范围。
|
||||
QueueID core.ID `json:"queue_id"` // QueueID 初始队列标识。
|
||||
CredentialID core.ID `json:"credential_id"` // CredentialID 凭据记录标识。
|
||||
Token string `json:"token"` // Token 原始秘密,仅保存到受保护交付文件。
|
||||
ExpiresAt time.Time `json:"expires_at"` // ExpiresAt 初始凭据到期时间,UTC。
|
||||
ConsoleUsername string `json:"console_username,omitempty"` // ConsoleUsername 可选的首个控制台账号,不包含密码。
|
||||
}
|
||||
|
||||
func Bootstrap(ctx context.Context, db *sql.DB, in BootstrapInput, deliver func(BootstrapResult) error) error {
|
||||
@@ -54,6 +60,9 @@ func Bootstrap(ctx context.Context, db *sql.DB, in BootstrapInput, deliver func(
|
||||
if !service.ValidName(in.Environment, 32) || deliver == nil {
|
||||
return fmt.Errorf("environment and secure delivery are required")
|
||||
}
|
||||
if (in.ConsoleUsername == "") != (in.ConsolePassword == "") || (in.ConsoleUsername == "" && in.ConsoleDisplayName != "") {
|
||||
return fmt.Errorf("console username and password must be provided together")
|
||||
}
|
||||
if err := CheckSchema(ctx, db); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -73,6 +82,11 @@ func Bootstrap(ctx context.Context, db *sql.DB, in BootstrapInput, deliver func(
|
||||
}
|
||||
*dest = v
|
||||
}
|
||||
if in.ConsoleUsername != "" {
|
||||
if err := validateConsoleUserInput(ConsoleUserInput{NamespaceID: out.NamespaceID, CredentialID: out.CredentialID, Username: in.ConsoleUsername, DisplayName: in.ConsoleDisplayName, Password: in.ConsolePassword}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
secret, digest, err := auth.Issue(out.NamespaceID, out.CredentialID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -118,6 +132,19 @@ func Bootstrap(ctx context.Context, db *sql.DB, in BootstrapInput, deliver func(
|
||||
if err := permissions.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
var consolePasswordHash []byte
|
||||
var consoleUserID core.ID
|
||||
if in.ConsoleUsername != "" {
|
||||
consolePasswordHash, err = bcrypt.GenerateFromPassword([]byte(in.ConsolePassword), consolePasswordCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
consoleUserID, err = core.NewID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out.ConsoleUsername = in.ConsoleUsername
|
||||
}
|
||||
raw, err := json.Marshal(permissions)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -150,17 +177,37 @@ func Bootstrap(ctx context.Context, db *sql.DB, in BootstrapInput, deliver func(
|
||||
if _, err = tx.ExecContext(ctx, "INSERT INTO orbit_credentials(namespace_id,id,name,secret_digest,permissions,expires_at) VALUES (?,?,'bootstrap',?,?,?)", out.NamespaceID, out.CredentialID, digest, string(raw), out.ExpiresAt); err != nil {
|
||||
return err
|
||||
}
|
||||
op, err := core.NewID()
|
||||
actor, err := databaseActor(ctx, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
actor, err := databaseActor(ctx, tx)
|
||||
op, err := core.NewID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := appendAuditActor(ctx, tx, out.NamespaceID, actor, op, "bootstrap.create", out.CredentialID, "database administrator bootstrap", nil, map[string]any{"permissions": permissions, "expires_at": out.ExpiresAt}); err != nil {
|
||||
return err
|
||||
}
|
||||
if in.ConsoleUsername != "" {
|
||||
name := strings.TrimSpace(in.ConsoleDisplayName)
|
||||
if name == "" {
|
||||
name = in.ConsoleUsername
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO orbit_console_users
|
||||
(id,namespace_id,credential_id,username,display_name,password_hash)
|
||||
VALUES (?,?,?,?,?,?)`, consoleUserID, out.NamespaceID, out.CredentialID, in.ConsoleUsername, name, string(consolePasswordHash)); err != nil {
|
||||
return err
|
||||
}
|
||||
consoleOperationID, err := core.NewID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := appendAuditActor(ctx, tx, out.NamespaceID, actor, consoleOperationID, "console_user.create", consoleUserID, "bootstrap console administrator", nil, map[string]any{
|
||||
"username": in.ConsoleUsername, "display_name": name, "credential_id": out.CredentialID,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// 交付文件先持久化再提交;提交失败时保留文件以便核对不确定结果。
|
||||
return deliver(out)
|
||||
})
|
||||
|
||||
35
internal/storage/mysql/bootstrap_test.go
Normal file
35
internal/storage/mysql/bootstrap_test.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBootstrapConsoleAdministratorRequiresUsernameAndPassword(t *testing.T) {
|
||||
base := BootstrapInput{Application: "app", Namespace: "default", Environment: "test", Queue: "queue"}
|
||||
for name, in := range map[string]BootstrapInput{
|
||||
"username without password": func() BootstrapInput {
|
||||
in := base
|
||||
in.ConsoleUsername = "admin"
|
||||
return in
|
||||
}(),
|
||||
"password without username": func() BootstrapInput {
|
||||
in := base
|
||||
in.ConsolePassword = "a sufficiently long password"
|
||||
return in
|
||||
}(),
|
||||
"display name without account": func() BootstrapInput {
|
||||
in := base
|
||||
in.ConsoleDisplayName = "Orbit 管理员"
|
||||
return in
|
||||
}(),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
err := Bootstrap(context.Background(), nil, in, func(BootstrapResult) error { return nil })
|
||||
if err == nil || !strings.Contains(err.Error(), "console username and password") {
|
||||
t.Fatalf("expected console administrator validation error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user