package mysql import ( "context" "crypto/sha256" "database/sql" "encoding/json" "errors" "time" "git.yuntuo.hk/k2safe/orbit/internal/consoleauth" "git.yuntuo.hk/k2safe/orbit/internal/credentialvault" "git.yuntuo.hk/k2safe/orbit/internal/fault" core "git.yuntuo.hk/k2safe/orbit/model" "git.yuntuo.hk/k2safe/orbit/platform/auth" "git.yuntuo.hk/k2safe/orbit/platform/events" "git.yuntuo.hk/k2safe/orbit/pulse/service" driver "github.com/go-sql-driver/mysql" ) // ConsoleApplicationStore 仅允许部署显式指定的单管理员管理跨应用接入。 // 使用运行数据库的 DML 权限,不需要迁移或数据库管理员权限。 type ConsoleApplicationStore struct { db *sql.DB username string vault *credentialvault.Keyring } func NewConsoleApplicationStore(db *sql.DB, username string, vault ...*credentialvault.Keyring) *ConsoleApplicationStore { s := &ConsoleApplicationStore{db: db, username: username} if len(vault) > 0 { s.vault = vault[0] } return s } var _ consoleauth.Applications = (*ConsoleApplicationStore)(nil) // admin 在实际读写事务中锁定人类账号及会话;普通 SDK Token 无对应会话,无法通过。 // 不锁业务凭据,轮换当前账号所绑定的 SDK 凭据也不会产生共享锁升级。 func (s *ConsoleApplicationStore) admin(ctx context.Context, tx *sql.Tx, token auth.Token) (string, error) { if s.username == "" || token.NamespaceID.Validate() != nil || token.CredentialID.Validate() != nil { return "", fault.Forbidden } var user core.ID err := tx.QueryRowContext(ctx, `SELECT u.id FROM orbit_console_sessions s JOIN orbit_console_users u ON u.id=s.user_id WHERE s.namespace_id=? AND s.credential_id=? AND s.token_digest=? AND s.revoked_at IS NULL AND s.expires_at>UTC_TIMESTAMP(6) AND u.enabled=TRUE AND u.username=? FOR SHARE OF u,s`, token.NamespaceID, token.CredentialID, token.Digest, s.username).Scan(&user) if errors.Is(err, sql.ErrNoRows) { return "", fault.Forbidden } return "console:" + string(user), err } func (s *ConsoleApplicationStore) ListApplications(ctx context.Context, token auth.Token, after core.ID) (consoleauth.ApplicationPage, error) { out := consoleauth.ApplicationPage{Items: []consoleauth.ApplicationEntry{}} if after != "" && after.Validate() != nil { return out, fault.Invalid("invalid application cursor") } err := WithTx(ctx, s.db, func(tx *sql.Tx) error { if _, err := s.admin(ctx, tx, token); err != nil { return err } query := `SELECT a.id,a.name,n.id,n.name,n.environment,(a.enabled AND n.enabled) FROM orbit_namespaces n JOIN orbit_applications a ON a.id=n.application_id` args := []any{} if after != "" { query += ` WHERE n.id>?` args = append(args, after) } rows, err := tx.QueryContext(ctx, query+` ORDER BY n.id LIMIT 51`, args...) if err != nil { return err } defer rows.Close() for rows.Next() { var e consoleauth.ApplicationEntry if err := rows.Scan(&e.ApplicationID, &e.Application, &e.NamespaceID, &e.Namespace, &e.Environment, &e.Enabled); err != nil { return err } out.Items = append(out.Items, e) } if len(out.Items) > 50 { out.Items = out.Items[:50] out.Next = out.Items[49].NamespaceID } return rows.Err() }) return out, err } func (s *ConsoleApplicationStore) GetApplication(ctx context.Context, token auth.Token, namespace core.ID) (consoleauth.ApplicationDetail, error) { out := consoleauth.ApplicationDetail{Queues: []consoleauth.AccessQueue{}, Credentials: []consoleauth.AccessCredential{}} if namespace.Validate() != nil { return out, fault.Invalid("invalid namespace") } err := WithTx(ctx, s.db, func(tx *sql.Tx) error { if _, err := s.admin(ctx, tx, token); err != nil { return err } e := &out.Entry err := tx.QueryRowContext(ctx, `SELECT a.id,a.name,n.id,n.name,n.environment,(a.enabled AND n.enabled) FROM orbit_namespaces n JOIN orbit_applications a ON a.id=n.application_id WHERE n.id=?`, namespace).Scan(&e.ApplicationID, &e.Application, &e.NamespaceID, &e.Namespace, &e.Environment, &e.Enabled) if err == sql.ErrNoRows { return fault.NotFound } if err != nil { return err } rows, err := tx.QueryContext(ctx, `SELECT id,name,paused,retired_at FROM pulse_queues WHERE namespace_id=? ORDER BY (retired_at IS NOT NULL),paused,created_at,id LIMIT 101`, namespace) if err != nil { return err } for rows.Next() { var q consoleauth.AccessQueue if err := rows.Scan(&q.ID, &q.Name, &q.Paused, &q.RetiredAt); err != nil { rows.Close() return err } out.Queues = append(out.Queues, q) } err = rows.Err() rows.Close() if err != nil { return err } out.SubscriptionLimit = events.MaxSubscriptions if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM orbit_subscriptions WHERE namespace_id=? AND retired_at IS NULL`, namespace).Scan(&out.ActiveSubscriptions); err != nil { return err } out.TokenVaultEnabled = s.vault != nil rows, err = tx.QueryContext(ctx, `SELECT id,name,revision,expires_at,revoked_at,previous_valid_until,permissions,secret_ciphertext IS NOT NULL FROM orbit_credentials WHERE namespace_id=? ORDER BY id LIMIT 101`, namespace) if err != nil { return err } defer rows.Close() for rows.Next() { var c consoleauth.AccessCredential var expires, revoked, previous sql.NullTime var raw []byte if err := rows.Scan(&c.ID, &c.Name, &c.Revision, &expires, &revoked, &previous, &raw, &c.TokenStored); err != nil { return err } if err := json.Unmarshal(raw, &c.Permissions); err != nil { return err } c.ExpiresAt = nullableTime(expires) c.RevokedAt = nullableTime(revoked) c.PreviousValidUntil = nullableTime(previous) out.Credentials = append(out.Credentials, c) } if len(out.Queues) > 100 { out.Truncated = true out.Queues = out.Queues[:100] } if len(out.Credentials) > 100 { out.Truncated = true out.Credentials = out.Credentials[:100] } return rows.Err() }) return out, err } var applicationCapabilities = map[string][]string{ "worker": {"workers:connect", "workers:consume"}, "schedules": {"schedules:read", "schedules:manage"}, "events": {"events:read", "events:manage", "events:consume"}, "workflows": {"workflows:definitions:read", "workflows:definitions:publish", "workflows:start", "workflows:read", "workflows:control", "workflows:signal", "workflows:audit:read"}, "recovery": {"tasks:review", "tasks:retry", "tasks:audit:read", "workflows:recover", "workflows:read"}, "resources": {"application:resources:read", "application:resources:manage"}, } func applicationPermissions(in consoleauth.ApplicationInput) (auth.Permissions, error) { p := auth.Permissions{Actions: []string{"tasks:submit", "tasks:read", "tasks:cancel", "queues:read", "queues:manage"}, Queues: []string{"*"}} if len(in.Capabilities) > len(applicationCapabilities) { return p, fault.Invalid("invalid capabilities") } seen := map[string]bool{} actions := map[string]bool{} for _, group := range in.Capabilities { list, ok := applicationCapabilities[group] if !ok || seen[group] { return p, fault.Invalid("invalid or duplicate capability") } seen[group] = true for _, a := range list { if !actions[a] { p.Actions = append(p.Actions, a) actions[a] = true } } } return p, p.Validate() } func validateApplicationInput(in consoleauth.ApplicationInput) error { if in.ValidDays < 0 || in.ValidDays > 365 { return fault.Invalid("valid_days must be 0 (permanent) or 1-365") } if in.GraceDays < 0 || in.GraceDays > 30 || (in.Kind != "ROTATE" && in.GraceDays != 0) { return fault.Invalid("grace_days must be 0-30 for rotation only") } if in.Kind == "FINALIZE" && in.ValidDays != 0 { return fault.Invalid("finalize cannot set validity") } if err := validCredentialReason(in.Reason); err != nil { return err } switch in.Kind { case "CREATE": if !service.ValidName(in.Application, 128) || !service.ValidName(in.Namespace, 128) || !service.ValidName(in.Environment, 32) || !service.ValidName(in.Queue, 128) || in.NamespaceID != "" || in.CredentialID != "" || in.ExpectedRevision != 0 { return fault.Invalid("invalid new application fields") } _, err := applicationPermissions(in) return err case "ROTATE", "SET_EXPIRY", "FINALIZE": if in.NamespaceID.Validate() != nil || in.CredentialID.Validate() != nil || in.ExpectedRevision == 0 || in.Application != "" || in.Namespace != "" || in.Environment != "" || in.Queue != "" || len(in.Capabilities) != 0 { return fault.Invalid("invalid rotation fields") } default: return fault.Invalid("kind must be CREATE, ROTATE, SET_EXPIRY or FINALIZE") } return nil } func (s *ConsoleApplicationStore) PrepareApplication(ctx context.Context, token auth.Token, in consoleauth.ApplicationInput) (consoleauth.ApplicationOperation, error) { out := consoleauth.ApplicationOperation{Version: 2, Input: in} if err := validateApplicationInput(in); err != nil { return out, err } err := WithTx(ctx, s.db, func(tx *sql.Tx) error { if _, err := s.admin(ctx, tx, token); err != nil { return err } now, err := dbNow(ctx, tx) if err != nil { return err } if in.ValidDays > 0 { out.ExpiresAt = timePtr(now.Add(time.Duration(in.ValidDays) * 24 * time.Hour)) } if in.GraceDays > 0 { out.PreviousValidUntil = timePtr(now.Add(time.Duration(in.GraceDays) * 24 * time.Hour)) } out.OperationID, err = core.NewID() if err != nil { return err } if in.Kind == "CREATE" { for _, id := range []*core.ID{&out.ApplicationID, &out.NamespaceID, &out.QueueID, &out.CredentialID} { *id, err = core.NewID() if err != nil { return err } } } else { out.NamespaceID, out.CredentialID = in.NamespaceID, in.CredentialID var revision uint64 var revoked sql.NullTime err := tx.QueryRowContext(ctx, `SELECT n.application_id,c.revision,c.revoked_at FROM orbit_credentials c JOIN orbit_namespaces n ON n.id=c.namespace_id WHERE c.namespace_id=? AND c.id=?`, in.NamespaceID, in.CredentialID).Scan(&out.ApplicationID, &revision, &revoked) if err == sql.ErrNoRows { return fault.NotFound } if err != nil { return err } if revision != in.ExpectedRevision || revoked.Valid { return fault.Conflict } } if in.Kind == "CREATE" || in.Kind == "ROTATE" { out.Token, _, err = auth.Issue(out.NamespaceID, out.CredentialID) } return err }) return out, err } func (s *ConsoleApplicationStore) ApplyApplication(ctx context.Context, token auth.Token, op consoleauth.ApplicationOperation) (consoleauth.ApplicationReceipt, error) { out := consoleauth.ApplicationReceipt{} if err := validateApplicationInput(op.Input); err != nil { return out, err } if (op.Version != 0 && op.Version != 2) || op.OperationID.Validate() != nil || op.ApplicationID.Validate() != nil || op.NamespaceID.Validate() != nil || op.CredentialID.Validate() != nil { return out, fault.Invalid("invalid fixed operation identity") } if (op.Input.ValidDays == 0) != (op.ExpiresAt == nil) || (op.Input.GraceDays == 0) != (op.PreviousValidUntil == nil) { return out, fault.Invalid("fixed lifetime does not match input") } version := op.Version if version == 0 { version = 1 } if op.Input.Kind != "CREATE" { if op.QueueID != "" || op.NamespaceID != op.Input.NamespaceID || op.CredentialID != op.Input.CredentialID { return out, fault.Invalid("credential identity mismatch") } change := CredentialOperation{Version: version, OperationID: op.OperationID, NamespaceID: op.NamespaceID, CredentialID: op.CredentialID, ExpectedRevision: op.Input.ExpectedRevision, Action: op.Input.Kind, Reason: op.Input.Reason, Token: op.Token, ExpiresAt: op.ExpiresAt, PreviousValidUntil: op.PreviousValidUntil} receipt, err := applyCredentialOperation(ctx, s.db, change, func(tx *sql.Tx) (string, error) { actor, err := s.admin(ctx, tx, token) if err != nil { return "", err } var app core.ID err = tx.QueryRowContext(ctx, `SELECT application_id FROM orbit_namespaces WHERE id=?`, op.NamespaceID).Scan(&app) if err == sql.ErrNoRows { return "", fault.NotFound } if err != nil { return "", err } if app != op.ApplicationID { return "", fault.Invalid("application identity mismatch") } return actor, nil }, s.vault) return consoleauth.ApplicationReceipt{OperationID: op.OperationID, ApplicationID: op.ApplicationID, NamespaceID: op.NamespaceID, CredentialID: op.CredentialID, Revision: receipt.Revision, Deduplicated: receipt.Deduplicated}, err } parsed, err := auth.Parse(op.Token) if err != nil || parsed.NamespaceID != op.NamespaceID || parsed.CredentialID != op.CredentialID || (version == 1 && op.ExpiresAt == nil) { return out, fault.Invalid("invalid new credential") } if op.QueueID.Validate() != nil { return out, fault.Invalid("invalid queue identity") } permissions, err := applicationPermissions(op.Input) if err != nil { return out, err } raw, _ := json.Marshal(permissions) digest, err := requestDigest(op) if err != nil { return out, err } keyDigest := core.Digest(sha256.Sum256([]byte(op.OperationID))) err = WithTx(ctx, s.db, func(tx *sql.Tx) error { actor, err := s.admin(ctx, tx, token) if err != nil { return err } _, err = tx.ExecContext(ctx, `INSERT INTO orbit_idempotency_keys(namespace_id,id,operation,scope_key,key_digest,request_digest,resource_kind,resource_id,response,expires_at) VALUES (?,?,'console.application.create.v1',?,?,?,'application',?,JSON_OBJECT(),DATE_ADD(UTC_TIMESTAMP(6),INTERVAL 90 DAY)) ON DUPLICATE KEY UPDATE id=orbit_idempotency_keys.id`, token.NamespaceID, op.OperationID, actor, keyDigest, digest, op.ApplicationID) if err != nil { return err } var stored core.Digest var response []byte err = tx.QueryRowContext(ctx, `SELECT request_digest,response FROM orbit_idempotency_keys WHERE namespace_id=? AND operation='console.application.create.v1' AND scope_key=? AND key_digest=? FOR UPDATE`, token.NamespaceID, actor, keyDigest).Scan(&stored, &response) if err != nil { return err } if subtleCompareDigest(stored, digest) != 1 { return fault.IdempotencyConflict } if string(response) != "{}" { if err := json.Unmarshal(response, &out); err != nil { return err } out.Deduplicated = true return nil } now, err := dbNow(ctx, tx) if err != nil { return err } if op.ExpiresAt != nil && (!op.ExpiresAt.After(now) || op.ExpiresAt.After(now.Add(365*24*time.Hour))) { return fault.Invalid("expired or excessive credential lifetime") } keyID, ciphertext, err := s.vault.Seal(op.NamespaceID, op.CredentialID, op.Token) if err != nil { return err } statements := []struct { query string args []any }{ {`INSERT INTO orbit_applications(id,name) VALUES (?,?)`, []any{op.ApplicationID, op.Input.Application}}, {`INSERT INTO orbit_application_resources(application_id) VALUES (?)`, []any{op.ApplicationID}}, {`INSERT INTO orbit_namespaces(id,application_id,name,environment) VALUES (?,?,?,?)`, []any{op.NamespaceID, op.ApplicationID, op.Input.Namespace, op.Input.Environment}}, {`INSERT INTO pulse_queues(namespace_id,id,name,max_concurrency,rate_per_second,policy) VALUES (?,?,?,4,10,'{}')`, []any{op.NamespaceID, op.QueueID, op.Input.Queue}}, {`INSERT INTO orbit_credentials(namespace_id,id,name,secret_digest,permissions,expires_at,secret_key_id,secret_ciphertext) VALUES (?,?,'console-issued',?,?,?,?,?)`, []any{op.NamespaceID, op.CredentialID, parsed.Digest, string(raw), op.ExpiresAt, keyID, ciphertext}}, } for _, st := range statements { if _, err := tx.ExecContext(ctx, st.query, st.args...); err != nil { var e *driver.MySQLError if errors.As(err, &e) && e.Number == 1062 { return fault.Conflict } return err } } if err := appendAuditActor(ctx, tx, op.NamespaceID, actor, op.OperationID, "console.application.create", op.CredentialID, op.Input.Reason, nil, map[string]any{"application_id": op.ApplicationID, "application": op.Input.Application, "queue_id": op.QueueID, "permissions": permissions, "expires_at": op.ExpiresAt}); err != nil { return err } out = consoleauth.ApplicationReceipt{OperationID: op.OperationID, ApplicationID: op.ApplicationID, NamespaceID: op.NamespaceID, CredentialID: op.CredentialID, Revision: 1} receipt, err := json.Marshal(out) if err != nil { return err } _, err = tx.ExecContext(ctx, `UPDATE orbit_idempotency_keys SET response=?,revision=revision+1 WHERE namespace_id=? AND id=?`, receipt, token.NamespaceID, op.OperationID) return err }) return out, err }