230 lines
11 KiB
Go
230 lines
11 KiB
Go
package mysql
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"encoding/json"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
"git.yuntuo.hk/k2safe/orbit/internal/credentialvault"
|
||
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。
|
||
type BootstrapInput struct {
|
||
ValidFor time.Duration // ValidFor 初始令牌有效时长;0 为长期,定期范围 1 小时至 365 天。
|
||
WorkflowAuditAccess bool // WorkflowAuditAccess 显式授予流程操作审计读取;不授予其他管理操作。
|
||
WorkflowRunAccess bool // WorkflowRunAccess 显式授予流程启动和读取,不包含发布或管理。
|
||
WorkflowControlAccess bool // WorkflowControlAccess 显式授予流程暂停、继续和取消及读取。
|
||
WorkflowSignalAccess bool // WorkflowSignalAccess 显式授予流程信号受理及读取。
|
||
WorkflowRecoveryAccess bool // WorkflowRecoveryAccess 显式授予有证据的流程人工恢复及读取。
|
||
WorkflowAccess bool // WorkflowAccess 显式授予流程定义读取和发布权限,不含实例执行权限。
|
||
RecoveryAccess bool // RecoveryAccess 显式授予任务核查、人工重试及操作审计读取权限。
|
||
EventAccess bool // EventAccess 显式增加结果订阅查询、管理和消费权限。
|
||
Application string // Application 新应用名称,与 ExistingApplicationID 二选一。
|
||
Namespace string // Namespace 命名空间名称。
|
||
Environment string // Environment 环境名称,必须显式指定。
|
||
Queue string // Queue 初始队列名称。
|
||
WorkerAccess bool // WorkerAccess 显式增加开发用 Worker 接入及消费权限。
|
||
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,omitempty"` // ExpiresAt 初始凭据到期时间,UTC;空为长期。
|
||
ConsoleUsername string `json:"console_username,omitempty"` // ConsoleUsername 可选的首个控制台账号,不包含密码。
|
||
}
|
||
|
||
func Bootstrap(ctx context.Context, db *sql.DB, in BootstrapInput, deliver func(BootstrapResult) error, vault ...*credentialvault.Keyring) error {
|
||
var ring *credentialvault.Keyring
|
||
if len(vault) > 0 {
|
||
ring = vault[0]
|
||
}
|
||
if in.ValidFor != 0 && (in.ValidFor < time.Hour || in.ValidFor > 365*24*time.Hour) {
|
||
return fmt.Errorf("validity must be zero or between one hour and one year")
|
||
}
|
||
for _, v := range []string{in.Namespace, in.Queue} {
|
||
if !service.ValidName(v, 128) {
|
||
return fmt.Errorf("invalid bootstrap resource name")
|
||
}
|
||
}
|
||
if (in.ExistingApplicationID == "" && !service.ValidName(in.Application, 128)) || (in.ExistingApplicationID != "" && (in.ExistingApplicationID.Validate() != nil || in.Application != "")) {
|
||
return fmt.Errorf("exactly one new application name or existing application id is required")
|
||
}
|
||
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
|
||
}
|
||
var out BootstrapResult
|
||
var err error
|
||
out.ApplicationID = in.ExistingApplicationID
|
||
if out.ApplicationID == "" {
|
||
out.ApplicationID, err = core.NewID()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
}
|
||
for _, dest := range []*core.ID{&out.NamespaceID, &out.QueueID, &out.CredentialID} {
|
||
v, err := core.NewID()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
*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
|
||
}
|
||
out.Token = secret
|
||
keyID, ciphertext, err := ring.Seal(out.NamespaceID, out.CredentialID, secret)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
permissions := auth.Permissions{Actions: []string{"tasks:submit", "tasks:read", "tasks:cancel", "queues:read", "queues:manage"}, Queues: []string{"*"}}
|
||
if in.WorkerAccess {
|
||
permissions.Actions = append(permissions.Actions, "workers:connect", "workers:consume")
|
||
}
|
||
if in.WorkflowAccess {
|
||
permissions.Actions = append(permissions.Actions, "workflows:definitions:read", "workflows:definitions:publish")
|
||
}
|
||
if in.WorkflowAuditAccess {
|
||
permissions.Actions = append(permissions.Actions, "workflows:audit:read")
|
||
}
|
||
if in.WorkflowRunAccess || in.WorkflowControlAccess || in.WorkflowSignalAccess || in.WorkflowRecoveryAccess {
|
||
permissions.Actions = append(permissions.Actions, "workflows:read")
|
||
}
|
||
if in.WorkflowRunAccess {
|
||
permissions.Actions = append(permissions.Actions, "workflows:start")
|
||
}
|
||
if in.WorkflowControlAccess {
|
||
permissions.Actions = append(permissions.Actions, "workflows:control")
|
||
}
|
||
if in.WorkflowSignalAccess {
|
||
permissions.Actions = append(permissions.Actions, "workflows:signal")
|
||
}
|
||
if in.WorkflowRecoveryAccess {
|
||
permissions.Actions = append(permissions.Actions, "workflows:recover")
|
||
}
|
||
if in.ScheduleAccess {
|
||
permissions.Actions = append(permissions.Actions, "schedules:read", "schedules:manage")
|
||
}
|
||
if in.ResourceAccess {
|
||
permissions.Actions = append(permissions.Actions, "application:resources:read", "application:resources:manage")
|
||
}
|
||
if in.EventAccess {
|
||
permissions.Actions = append(permissions.Actions, "events:read", "events:manage", "events:consume")
|
||
}
|
||
if in.RecoveryAccess {
|
||
permissions.Actions = append(permissions.Actions, "tasks:review", "tasks:retry", "tasks:audit:read")
|
||
}
|
||
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
|
||
}
|
||
return WithTx(ctx, db, func(tx *sql.Tx) error {
|
||
now, err := dbNow(ctx, tx)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if in.ValidFor > 0 {
|
||
out.ExpiresAt = timePtr(now.Add(in.ValidFor))
|
||
}
|
||
if in.ExistingApplicationID == "" {
|
||
if _, err = tx.ExecContext(ctx, "INSERT INTO orbit_applications(id,name) VALUES (?,?)", out.ApplicationID, in.Application); err != nil {
|
||
return err
|
||
}
|
||
if _, err = tx.ExecContext(ctx, "INSERT INTO orbit_application_resources(application_id) VALUES (?)", out.ApplicationID); err != nil {
|
||
return err
|
||
}
|
||
} else {
|
||
var id core.ID
|
||
if err := tx.QueryRowContext(ctx, `SELECT a.id FROM orbit_applications a JOIN orbit_application_resources r ON r.application_id=a.id WHERE a.id=? AND a.enabled=TRUE FOR SHARE`, out.ApplicationID).Scan(&id); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if _, err = tx.ExecContext(ctx, "INSERT INTO orbit_namespaces(id,application_id,name,environment) VALUES (?,?,?,?)", out.NamespaceID, out.ApplicationID, in.Namespace, in.Environment); err != nil {
|
||
return err
|
||
}
|
||
if _, err = tx.ExecContext(ctx, "INSERT INTO pulse_queues(namespace_id,id,name,max_concurrency,rate_per_second,policy) VALUES (?,?,?,4,10,'{}')", out.NamespaceID, out.QueueID, in.Queue); err != nil {
|
||
return err
|
||
}
|
||
if _, err = tx.ExecContext(ctx, "INSERT INTO orbit_credentials(namespace_id,id,name,secret_digest,permissions,expires_at,secret_key_id,secret_ciphertext) VALUES (?,?,'bootstrap',?,?,?,?,?)", out.NamespaceID, out.CredentialID, digest, string(raw), out.ExpiresAt, keyID, ciphertext); err != nil {
|
||
return err
|
||
}
|
||
actor, err := databaseActor(ctx, tx)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
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)
|
||
})
|
||
}
|