feat: add single-admin application onboarding and token rotation
This commit is contained in:
362
internal/storage/mysql/console_applications.go
Normal file
362
internal/storage/mysql/console_applications.go
Normal file
@@ -0,0 +1,362 @@
|
||||
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
|
||||
}
|
||||
165
internal/storage/mysql/console_applications_integration_test.go
Normal file
165
internal/storage/mysql/console_applications_integration_test.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
)
|
||||
|
||||
func TestIntegrationConsoleApplications(t *testing.T) {
|
||||
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("requires disposable MySQL")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
db, err := Open(ctx, dsn, 10, 3)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if err := Migrate(ctx, db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
username := "access-" + string(testID(t))
|
||||
password := "local-test-password-12345"
|
||||
var initial BootstrapResult
|
||||
if err := Bootstrap(ctx, db, BootstrapInput{Application: username, Namespace: "default", Environment: "test", Queue: "initial", ConsoleUsername: username, ConsolePassword: password}, func(v BootstrapResult) error { initial = v; return nil }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
login, err := NewConsoleAuthStore(db).Login(ctx, username, password, "test", "127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
admin, _ := auth.Parse(login.Token)
|
||||
sdk, _ := auth.Parse(initial.Token)
|
||||
store := NewConsoleApplicationStore(db, username)
|
||||
if _, err := store.ListApplications(ctx, sdk, ""); fault.Code(err) != "PERMISSION_DENIED" {
|
||||
t.Fatalf("SDK reached admin: %v", err)
|
||||
}
|
||||
if _, err := NewConsoleApplicationStore(db, "").ListApplications(ctx, admin, ""); fault.Code(err) != "PERMISSION_DENIED" {
|
||||
t.Fatal("disabled admin accepted")
|
||||
}
|
||||
if _, err := NewConsoleApplicationStore(db, "different-admin").ListApplications(ctx, admin, ""); fault.Code(err) != "PERMISSION_DENIED" {
|
||||
t.Fatal("other user accepted")
|
||||
}
|
||||
in := consoleauth.ApplicationInput{Kind: "CREATE", Application: "created-" + string(testID(t)), Namespace: "default", Environment: "test", Queue: "work", Capabilities: []string{"worker", "events", "schedules", "workflows"}, ValidDays: 30, Reason: "integration onboarding"}
|
||||
op, err := store.PrepareApplication(ctx, admin, in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 准备只生成秘密,尚未允许业务访问。
|
||||
candidate, _ := auth.Parse(op.Token)
|
||||
if err := NewTaskStore(db).AuthorizeSubmit(ctx, candidate); err == nil {
|
||||
t.Fatal("prepared token accepted before commit")
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
receipts := make([]consoleauth.ApplicationReceipt, 2)
|
||||
errs := make([]error, 2)
|
||||
for i := range receipts {
|
||||
wg.Add(1)
|
||||
go func(i int) { defer wg.Done(); receipts[i], errs[i] = store.ApplyApplication(ctx, admin, op) }(i)
|
||||
}
|
||||
wg.Wait()
|
||||
for _, err := range errs {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if receipts[0].Deduplicated == receipts[1].Deduplicated {
|
||||
t.Fatal("concurrent original operation did not deduplicate")
|
||||
}
|
||||
if err := NewTaskStore(db).AuthorizeSubmit(ctx, candidate); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 同一操作身份不可改变请求,重名不能留下半套资源。
|
||||
changed := op
|
||||
changed.Input.Reason = "modified"
|
||||
if _, err := store.ApplyApplication(ctx, admin, changed); fault.Code(err) != "IDEMPOTENCY_CONFLICT" {
|
||||
t.Fatalf("changed operation accepted: %v", err)
|
||||
}
|
||||
duplicate, err := store.PrepareApplication(ctx, admin, in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.ApplyApplication(ctx, admin, duplicate); fault.Code(err) != "STATE_CONFLICT" {
|
||||
t.Fatalf("duplicate name accepted: %v", err)
|
||||
}
|
||||
var n int
|
||||
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM orbit_namespaces WHERE id=?`, duplicate.NamespaceID).Scan(&n); err != nil || n != 0 {
|
||||
t.Fatal("duplicate left namespace")
|
||||
}
|
||||
detail, err := store.GetApplication(ctx, admin, op.NamespaceID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if detail.Entry.ApplicationID != op.ApplicationID || len(detail.Queues) != 1 || len(detail.Credentials) != 1 || !detail.Credentials[0].Permissions.Allows("events:consume") {
|
||||
t.Fatal("incomplete onboarding")
|
||||
}
|
||||
raw, _ := json.Marshal(detail)
|
||||
if strings.Contains(string(raw), op.Token) || strings.Contains(string(raw), "secret_digest") {
|
||||
t.Fatal("detail leaked credential")
|
||||
}
|
||||
rotation := consoleauth.ApplicationInput{Kind: "ROTATE", NamespaceID: op.NamespaceID, CredentialID: op.CredentialID, ExpectedRevision: 1, ValidDays: 90, Reason: "scheduled rotation"}
|
||||
rotated, err := store.PrepareApplication(ctx, admin, rotation)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
receipt, err := store.ApplyApplication(ctx, admin, rotated)
|
||||
if err != nil || receipt.Revision != 2 {
|
||||
t.Fatalf("rotate: %v", err)
|
||||
}
|
||||
newToken, _ := auth.Parse(rotated.Token)
|
||||
if NewTaskStore(db).AuthorizeSubmit(ctx, candidate) == nil || NewTaskStore(db).AuthorizeSubmit(ctx, newToken) != nil {
|
||||
t.Fatal("rotation auth boundary failed")
|
||||
}
|
||||
replay, err := store.ApplyApplication(ctx, admin, rotated)
|
||||
if err != nil || !replay.Deduplicated {
|
||||
t.Fatalf("rotation replay: %v", err)
|
||||
}
|
||||
if _, err := store.PrepareApplication(ctx, admin, rotation); fault.Code(err) != "STATE_CONFLICT" {
|
||||
t.Fatalf("stale revision accepted: %v", err)
|
||||
}
|
||||
detail, err = store.GetApplication(ctx, admin, op.NamespaceID)
|
||||
if err != nil || len(detail.Credentials) != 1 || detail.Credentials[0].ID != op.CredentialID || detail.Queues[0].ID != op.QueueID {
|
||||
t.Fatal("rotation changed identity")
|
||||
}
|
||||
rows, err := db.QueryContext(ctx, `SELECT actor_id,CAST(`+"`after`"+` AS CHAR) FROM orbit_audit_events WHERE namespace_id=? AND action IN ('console.application.create','credential.rotate')`, op.NamespaceID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
count := 0
|
||||
for rows.Next() {
|
||||
var actor, body string
|
||||
if err := rows.Scan(&actor, &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if actor != "console:"+login.User.ID || strings.Contains(body, op.Token) || strings.Contains(body, rotated.Token) {
|
||||
t.Fatal("audit principal or secrecy")
|
||||
}
|
||||
count++
|
||||
}
|
||||
err = rows.Err()
|
||||
rows.Close()
|
||||
if err != nil || count != 2 {
|
||||
t.Fatalf("audit count=%d err=%v", count, err)
|
||||
}
|
||||
// 登出在数据库生效后,原会话不能再次应用操作,即使持有原回执内容。
|
||||
if err := NewConsoleAuthStore(db).RevokeSession(ctx, admin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.ApplyApplication(ctx, admin, rotated); fault.Code(err) != "PERMISSION_DENIED" {
|
||||
t.Fatalf("logged out replay accepted: %v", err)
|
||||
}
|
||||
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.referential_constraints WHERE constraint_schema=DATABASE()`).Scan(&n); err != nil || n != 0 {
|
||||
t.Fatalf("foreign keys=%d err=%v", n, err)
|
||||
}
|
||||
}
|
||||
@@ -209,6 +209,11 @@ func ValidateCredentialOperation(op CredentialOperation) error {
|
||||
// receipt. No callback or file I/O occurs inside the transaction. A commit error
|
||||
// is intentionally returned unchanged; replaying the same operation reconciles it.
|
||||
func ApplyCredentialOperation(ctx context.Context, db *sql.DB, op CredentialOperation) (CredentialChangeReceipt, error) {
|
||||
return applyCredentialOperation(ctx, db, op, nil)
|
||||
}
|
||||
|
||||
// applyCredentialOperation 允许控制台在同一事务先核验管理员,并使用真实人类审计主体。
|
||||
func applyCredentialOperation(ctx context.Context, db *sql.DB, op CredentialOperation, authorize func(*sql.Tx) (string, error)) (CredentialChangeReceipt, error) {
|
||||
var out CredentialChangeReceipt
|
||||
if err := ValidateCredentialOperation(op); err != nil {
|
||||
return out, err
|
||||
@@ -217,6 +222,14 @@ func ApplyCredentialOperation(ctx context.Context, db *sql.DB, op CredentialOper
|
||||
return out, err
|
||||
}
|
||||
err := WithTx(ctx, db, func(tx *sql.Tx) error {
|
||||
actor := ""
|
||||
if authorize != nil {
|
||||
var err error
|
||||
actor, err = authorize(tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var name string
|
||||
var revision uint64
|
||||
var expires, revoked sql.NullTime
|
||||
@@ -286,9 +299,11 @@ func ApplyCredentialOperation(ctx context.Context, db *sql.DB, op CredentialOper
|
||||
out.Action = op.Action
|
||||
out.Revision = revision + 1
|
||||
out.AppliedAt = now
|
||||
actor, err := databaseActor(ctx, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
if actor == "" {
|
||||
actor, err = databaseActor(ctx, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := appendCredentialAudit(ctx, tx, actor, op, before, after); err != nil {
|
||||
return err
|
||||
|
||||
Reference in New Issue
Block a user