From 6a25a718c4a8f8b319de089388dd3288c3915ea3 Mon Sep 17 00:00:00 2001 From: k2safe Date: Mon, 21 Sep 2026 05:13:47 +0800 Subject: [PATCH] feat: bootstrap console administrator --- cmd/orbit-admin/main.go | 19 +++++++ docs/api/authentication.md | 17 +++++++ docs/console.md | 23 ++++++++- internal/storage/mysql/bootstrap.go | 63 +++++++++++++++++++++--- internal/storage/mysql/bootstrap_test.go | 35 +++++++++++++ 5 files changed, 147 insertions(+), 10 deletions(-) create mode 100644 internal/storage/mysql/bootstrap_test.go diff --git a/cmd/orbit-admin/main.go b/cmd/orbit-admin/main.go index fe36fc4..54b8bb2 100644 --- a/cmd/orbit-admin/main.go +++ b/cmd/orbit-admin/main.go @@ -258,6 +258,11 @@ func runBootstrap(args []string) error { flags.StringVar(&in.Environment, "environment", "", "environment name") flags.StringVar(&in.Queue, "queue", "", "initial queue name") flags.StringVar(&output, "output", "", "new private credentials JSON file") + var consoleUsername, consoleDisplayName, consolePassword string + var consolePasswordStdin bool + flags.StringVar(&consoleUsername, "console-username", "", "optional first console administrator username") + flags.StringVar(&consoleDisplayName, "console-display-name", "", "optional console administrator display name") + flags.BoolVar(&consolePasswordStdin, "console-password-stdin", false, "read the first console administrator password from stdin") flags.BoolVar(&in.WorkerAccess, "worker-access", false, "also grant Worker connect and consume actions for development") flags.BoolVar(&in.ScheduleAccess, "schedule-access", false, "also grant schedule read and manage actions for development") flags.BoolVar(&in.EventAccess, "event-access", false, "also grant result subscription read, manage and consume actions") @@ -274,6 +279,20 @@ func runBootstrap(args []string) error { if flags.NArg() != 0 || output == "" { return errors.New("--output and named resource arguments are required") } + if consolePasswordStdin { + raw, err := io.ReadAll(io.LimitReader(os.Stdin, 1025)) + if err != nil { + return errors.New("console administrator password could not be read") + } + consolePassword = strings.TrimRight(string(raw), "\r\n") + } + if consoleUsername == "" && (consolePasswordStdin || consoleDisplayName != "") { + return errors.New("--console-username is required with console administrator options") + } + if consoleUsername != "" && !consolePasswordStdin { + return errors.New("--console-password-stdin is required when creating the first console administrator") + } + in.ConsoleUsername, in.ConsoleDisplayName, in.ConsolePassword = consoleUsername, consoleDisplayName, consolePassword cfg, err := config.Load() if err != nil { return err diff --git a/docs/api/authentication.md b/docs/api/authentication.md index f54cc6a..f53f2cc 100644 --- a/docs/api/authentication.md +++ b/docs/api/authentication.md @@ -21,6 +21,23 @@ ORBIT_MYSQL_DSN 通过环境注入,命令不读取 .env。所有名称必填 输出 JSON 包含 application_id / namespace_id / queue_id / credential_id / token / expires_at。初始凭据有效期为 30 天,权限包括当前命名空间内的五个 P1 动作和全部队列。初始队列 max_concurrency=4、rate_per_second=10、max_unfinished_tasks=100000;并发、开始限速与积压背压已生效,语义及管理方式见 [队列接口](queues.md)。这些默认值是开发基线,不是容量承诺。 +如果希望在第一次初始化时同时创建控制台管理员,可在同一条 bootstrap 事务中提供账号,并从标准输入交付密码: + +```sh +secret-manager read orbit/console-admin-password | \ + go run ./cmd/orbit-admin bootstrap \ + --application sample-app \ + --namespace default \ + --environment production \ + --queue files \ + --console-username admin \ + --console-display-name 'Orbit 管理员' \ + --console-password-stdin \ + --output .local/production.credentials.json +``` + +`--console-password-stdin` 要求密码至少 12 个字节,密码不会出现在命令参数、日志或交付 JSON 中。业务凭据、控制台账号和两条初始化审计会在同一个数据库事务中提交;任何一步失败都会整体回滚。交付 JSON 只会额外包含 `console_username`,后续新增控制台账号仍使用 [`orbit-admin console-user create`](../console.md)。 + 资源、凭据摘要和初始化审计在一个事务中提交。交付文件先写入并同步到磁盘,成功后再提交事务。如果文件写入失败则回滚并移除文件;如果已经交付后提交无法确认,保留文件并报错,应使用其中凭据核对结果,不能直接覆盖或重复创建。 初始文件包含真实秘密,应保管在受限目录或导入秘密管理系统。运行账户与迁移/初始化账户分开;生产只通过 HTTPS 使用凭据。 diff --git a/docs/console.md b/docs/console.md index 3ea4262..f2d3d8c 100644 --- a/docs/console.md +++ b/docs/console.md @@ -32,7 +32,22 @@ http://127.0.0.1:8080/console/ http://127.0.0.1:8080/console/ ``` -首次使用需要由数据库管理员创建一个控制台账号。账号绑定一个已有的命名空间凭据,账号权限就是该凭据的权限;这不会轮换或输出业务 Token。推荐从私有凭据文件读取命名空间和凭据标识,并从标准输入交付密码: +首次使用可以在 `orbit-admin bootstrap` 时一起创建控制台管理员。这样业务资源、业务凭据和管理员账号在同一个初始化事务中提交: + +```sh +secret-manager read orbit/console-admin-password | \ + ./bin/orbit-admin bootstrap \ + --application sample-app \ + --namespace default \ + --environment production \ + --queue files \ + --console-username admin \ + --console-display-name 'Orbit 管理员' \ + --console-password-stdin \ + --output .local/production.credentials.json +``` + +密码不会进入命令参数、日志或 JSON 文件,初始化失败时资源和账号一起回滚。已经完成业务初始化的环境,也可以由数据库管理员单独创建控制台账号。账号绑定一个已有的命名空间凭据,账号权限就是该凭据的权限;这不会轮换或输出业务 Token。推荐从私有凭据文件读取命名空间和凭据标识,并从标准输入交付密码: ```sh ./bin/orbit-admin console-user create \ @@ -44,7 +59,11 @@ http://127.0.0.1:8080/console/ 命令会把密码写成 bcrypt 哈希,只输出账号的脱敏信息。生产环境应通过受保护的密钥管道提供标准输入;不要把密码写进命令历史、镜像或仓库。账号创建后直接打开控制台,用账号密码登录。 -`ORBIT_CONSOLE_QUEUE_ID` 只作为单一默认队列的展示和查询范围配置,页面不再要求填写 API 地址、队列 ID、Bearer Token,也不再选择凭据 JSON: +页面的 API 地址默认自动取当前访问地址的 origin,不需要手填。部署到域名后,直接访问 `https://orbit.example.com/console/`,页面会自动请求同一域名下的服务端接口;生产建议用反向代理让控制台和 API 保持同源。页面不再要求填写队列 ID、Bearer Token,也不再选择凭据 JSON。 + +如果未来把前端静态资源和服务端拆成不同域名,需要额外设计 API 基地址、CORS、Cookie `SameSite`/Domain 和 CSRF 防护,不能只替换一个 URL。当前内置控制台优先保证同源部署的安全边界。 + +`ORBIT_CONSOLE_QUEUE_ID` 只作为单一默认队列的展示和查询范围配置: ```sh export ORBIT_CONSOLE_QUEUE_ID='queue_xxx' diff --git a/internal/storage/mysql/bootstrap.go b/internal/storage/mysql/bootstrap.go index 4cc4a30..762b8c5 100644 --- a/internal/storage/mysql/bootstrap.go +++ b/internal/storage/mysql/bootstrap.go @@ -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) }) diff --git a/internal/storage/mysql/bootstrap_test.go b/internal/storage/mysql/bootstrap_test.go new file mode 100644 index 0000000..63fc2c1 --- /dev/null +++ b/internal/storage/mysql/bootstrap_test.go @@ -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) + } + }) + } +}