363 lines
14 KiB
Go
363 lines
14 KiB
Go
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/fault"
|
|
core "git.yuntuo.hk/k2safe/orbit/model"
|
|
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
|
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
|
driver "github.com/go-sql-driver/mysql"
|
|
)
|
|
|
|
// ConsoleApplicationStore 仅允许部署显式指定的单管理员管理跨应用接入。
|
|
// 使用运行数据库的 DML 权限,不需要迁移或数据库管理员权限。
|
|
type ConsoleApplicationStore struct {
|
|
db *sql.DB
|
|
username string
|
|
}
|
|
|
|
func NewConsoleApplicationStore(db *sql.DB, username string) *ConsoleApplicationStore {
|
|
return &ConsoleApplicationStore{db: db, username: username}
|
|
}
|
|
|
|
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 == "" {
|
|
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 FROM pulse_queues WHERE namespace_id=? ORDER BY id LIMIT 101`, namespace)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for rows.Next() {
|
|
var q consoleauth.AccessQueue
|
|
if err := rows.Scan(&q.ID, &q.Name); err != nil {
|
|
rows.Close()
|
|
return err
|
|
}
|
|
out.Queues = append(out.Queues, q)
|
|
}
|
|
err = rows.Err()
|
|
rows.Close()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rows, err = tx.QueryContext(ctx, `SELECT id,name,revision,expires_at,revoked_at,permissions 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 sql.NullTime
|
|
var raw []byte
|
|
if err := rows.Scan(&c.ID, &c.Name, &c.Revision, &expires, &revoked, &raw); err != nil {
|
|
return err
|
|
}
|
|
if err := json.Unmarshal(raw, &c.Permissions); err != nil {
|
|
return err
|
|
}
|
|
c.ExpiresAt = nullableTime(expires)
|
|
c.RevokedAt = nullableTime(revoked)
|
|
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 < 1 || in.ValidDays > 365 {
|
|
return fault.Invalid("valid_days must be 1-365")
|
|
}
|
|
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":
|
|
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 or ROTATE")
|
|
}
|
|
return nil
|
|
}
|
|
func (s *ConsoleApplicationStore) PrepareApplication(ctx context.Context, token auth.Token, in consoleauth.ApplicationInput) (consoleauth.ApplicationOperation, error) {
|
|
out := consoleauth.ApplicationOperation{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
|
|
}
|
|
out.ExpiresAt = now.Add(time.Duration(in.ValidDays) * 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
|
|
}
|
|
}
|
|
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
|
|
}
|
|
parsed, err := auth.Parse(op.Token)
|
|
if err != nil || parsed.NamespaceID != op.NamespaceID || parsed.CredentialID != op.CredentialID || op.OperationID.Validate() != nil || op.ApplicationID.Validate() != nil || op.ExpiresAt.IsZero() {
|
|
return out, fault.Invalid("invalid fixed operation")
|
|
}
|
|
if op.Input.Kind == "ROTATE" {
|
|
if op.QueueID != "" || op.NamespaceID != op.Input.NamespaceID || op.CredentialID != op.Input.CredentialID {
|
|
return out, fault.Invalid("rotation identity mismatch")
|
|
}
|
|
change := CredentialOperation{Version: 1, OperationID: op.OperationID, NamespaceID: op.NamespaceID, CredentialID: op.CredentialID, ExpectedRevision: op.Input.ExpectedRevision, Action: "ROTATE", Reason: op.Input.Reason, Token: op.Token, ExpiresAt: &op.ExpiresAt}
|
|
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
|
|
})
|
|
return consoleauth.ApplicationReceipt{OperationID: op.OperationID, ApplicationID: op.ApplicationID, NamespaceID: op.NamespaceID, CredentialID: op.CredentialID, Revision: receipt.Revision, Deduplicated: receipt.Deduplicated}, err
|
|
}
|
|
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.After(now) || op.ExpiresAt.After(now.Add(365*24*time.Hour)) {
|
|
return fault.Invalid("expired or excessive credential lifetime")
|
|
}
|
|
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) VALUES (?,?,'console-issued',?,?,?)`, []any{op.NamespaceID, op.CredentialID, parsed.Digest, string(raw), op.ExpiresAt.UTC()}},
|
|
}
|
|
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
|
|
}
|