208 lines
9.2 KiB
Go
208 lines
9.2 KiB
Go
package mysql
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
|
|
"git.yuntuo.hk/k2safe/orbit/internal/credentialvault"
|
|
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
|
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
|
)
|
|
|
|
func TestIntegrationCredentialVault(t *testing.T) {
|
|
ctx, db := overlapDB(t)
|
|
ring, err := credentialvault.Read(strings.NewReader(`{"active":"test","keys":{"test":"` + base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{42}, 32)) + `"}}`))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
name := "vault-" + string(testID(t))
|
|
var initial BootstrapResult
|
|
if err := Bootstrap(ctx, db, BootstrapInput{Application: name, Namespace: "default", Environment: "test", Queue: "work", ConsoleUsername: name, ConsolePassword: "vault-test-password-12345"}, func(v BootstrapResult) error { initial = v; return nil }); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
login, err := NewConsoleAuthStore(db).Login(ctx, name, "vault-test-password-12345", "test", "127.0.0.1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
admin, _ := auth.Parse(login.Token)
|
|
sdk, _ := auth.Parse(initial.Token)
|
|
store := NewConsoleApplicationStore(db, name, ring)
|
|
in := consoleauth.CredentialTokenInput{ExpectedRevision: 1}
|
|
access := func(input consoleauth.CredentialTokenInput, importCurrent bool) (consoleauth.CredentialToken, error) {
|
|
return store.AccessCredentialToken(ctx, admin, initial.NamespaceID, initial.CredentialID, input, importCurrent)
|
|
}
|
|
for _, unauthorized := range []auth.Token{sdk, {}} {
|
|
if _, err := store.AccessCredentialToken(ctx, unauthorized, initial.NamespaceID, initial.CredentialID, in, false); fault.Code(err) != "PERMISSION_DENIED" {
|
|
t.Fatal("SDK or missing session accepted")
|
|
}
|
|
}
|
|
if _, err := NewConsoleApplicationStore(db, "other", ring).AccessCredentialToken(ctx, admin, initial.NamespaceID, initial.CredentialID, in, false); fault.Code(err) != "PERMISSION_DENIED" {
|
|
t.Fatal("wrong administrator accepted")
|
|
}
|
|
if _, err := access(in, false); fault.Code(err) != "STATE_CONFLICT" {
|
|
t.Fatal("legacy token unexpectedly revealed")
|
|
}
|
|
in.Token = initial.Token
|
|
// 审计失败时导入事务回滚,查看也不能把秘密交付给调用方。
|
|
trigger := "vault_audit_" + string(testID(t))
|
|
if _, err := db.ExecContext(ctx, "CREATE TRIGGER "+trigger+" BEFORE INSERT ON orbit_audit_events FOR EACH ROW BEGIN IF NEW.namespace_id=UNHEX('"+string(initial.NamespaceID)+"') AND NEW.action LIKE 'console.credential.token.%' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='vault audit failure test'; END IF; END"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.ExecContext(ctx, "DROP TRIGGER IF EXISTS "+trigger)
|
|
if got, err := access(in, true); err == nil || got.Token != "" {
|
|
t.Fatal("audit failure returned secret")
|
|
}
|
|
var imported bool
|
|
if err := db.QueryRowContext(ctx, `SELECT secret_ciphertext IS NOT NULL FROM orbit_credentials WHERE namespace_id=? AND id=?`, initial.NamespaceID, initial.CredentialID).Scan(&imported); err != nil || imported {
|
|
t.Fatal("failed import did not roll back")
|
|
}
|
|
if _, err := db.ExecContext(ctx, "DROP TRIGGER "+trigger); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := access(in, true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := access(in, true); err != nil {
|
|
t.Fatal("repeat import failed", err)
|
|
}
|
|
in.Token = ""
|
|
if _, err := db.ExecContext(ctx, "CREATE TRIGGER "+trigger+" BEFORE INSERT ON orbit_audit_events FOR EACH ROW BEGIN IF NEW.namespace_id=UNHEX('"+string(initial.NamespaceID)+"') AND NEW.action='console.credential.token.view' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='vault audit failure test'; END IF; END"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got, err := access(in, false); err == nil || got.Token != "" {
|
|
t.Fatal("read without audit returned secret")
|
|
}
|
|
if _, err := db.ExecContext(ctx, "DROP TRIGGER "+trigger); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got, err := access(in, false); err != nil || got.Token != initial.Token || got.Revision != 1 {
|
|
t.Fatal("import changed identity or cannot reveal")
|
|
}
|
|
var cipher []byte
|
|
if err := db.QueryRowContext(ctx, `SELECT secret_ciphertext FROM orbit_credentials WHERE namespace_id=? AND id=?`, initial.NamespaceID, initial.CredentialID).Scan(&cipher); err != nil || bytes.Contains(cipher, []byte(initial.Token)) {
|
|
t.Fatal("plaintext persisted")
|
|
}
|
|
detail, err := store.GetApplication(ctx, admin, initial.NamespaceID)
|
|
raw, _ := json.Marshal(detail)
|
|
if err != nil || !detail.Credentials[0].TokenStored || bytes.Contains(raw, []byte(initial.Token)) {
|
|
t.Fatal("metadata leaked or failed")
|
|
}
|
|
// 已启用密文保管的 CREATE、ROTATE、过渡期读取以及旧操作重放。
|
|
create, err := store.PrepareApplication(ctx, admin, consoleauth.ApplicationInput{Kind: "CREATE", Application: "vault-new-" + string(testID(t)), Namespace: "default", Environment: "test", Queue: "work", Reason: "vault integration"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := store.ApplyApplication(ctx, admin, create); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got, err := store.AccessCredentialToken(ctx, admin, create.NamespaceID, create.CredentialID, consoleauth.CredentialTokenInput{ExpectedRevision: 1}, false); err != nil || got.Token != create.Token {
|
|
t.Fatal("new app not encrypted")
|
|
}
|
|
rotate, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: initial.NamespaceID, CredentialID: initial.CredentialID, ExpectedRevision: 1, Action: "ROTATE", GraceFor: time.Hour, Reason: "vault rotate"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var wg sync.WaitGroup
|
|
wg.Add(2)
|
|
var readErr, rotateErr error
|
|
var read consoleauth.CredentialToken
|
|
go func() { defer wg.Done(); read, readErr = access(in, false) }()
|
|
go func() { defer wg.Done(); _, rotateErr = ApplyCredentialOperation(ctx, db, rotate, ring) }()
|
|
wg.Wait()
|
|
if rotateErr != nil || (readErr != nil && fault.Code(readErr) != "STATE_CONFLICT") || (readErr == nil && read.Token != initial.Token) {
|
|
t.Fatal("concurrent read/rotation inconsistent", rotateErr, readErr)
|
|
}
|
|
in.ExpectedRevision = 2
|
|
if got, err := access(in, false); err != nil || got.Token != rotate.Token {
|
|
t.Fatal("current token unavailable during grace")
|
|
}
|
|
in.Token = initial.Token
|
|
if _, err := access(in, true); fault.Code(err) != "INVALID_ARGUMENT" {
|
|
t.Fatal("old generation imported")
|
|
}
|
|
in.Token = ""
|
|
status, err := GetCredentialStatus(ctx, db, initial.NamespaceID, initial.CredentialID)
|
|
if err != nil || status.PreviousValidUntil == nil {
|
|
t.Fatal("grace lost")
|
|
}
|
|
for _, action := range []string{"SET_EXPIRY", "FINALIZE"} {
|
|
op, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: initial.NamespaceID, CredentialID: initial.CredentialID, ExpectedRevision: in.ExpectedRevision, Action: action, Reason: "preserve vault"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err = ApplyCredentialOperation(ctx, db, op, ring); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
in.ExpectedRevision++
|
|
if got, err := access(in, false); err != nil || got.Token != rotate.Token {
|
|
t.Fatal("non-secret change invalidated encryption")
|
|
}
|
|
}
|
|
if _, err := ApplyCredentialOperation(ctx, db, rotate, ring); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got, err := access(in, false); err != nil || got.Token != rotate.Token {
|
|
t.Fatal("replay changed ciphertext")
|
|
}
|
|
// 撤销会清除密文;历史 CLI 不配置密钥时轮换也不能遗留过期密文。
|
|
op, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: initial.NamespaceID, CredentialID: initial.CredentialID, ExpectedRevision: in.ExpectedRevision, Action: "ROTATE", Reason: "legacy CLI"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err = ApplyCredentialOperation(ctx, db, op); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
in.ExpectedRevision++
|
|
if _, err := access(in, false); fault.Code(err) != "STATE_CONFLICT" {
|
|
t.Fatal("stale ciphertext remained after CLI rotation")
|
|
}
|
|
in.Token = op.Token
|
|
if _, err := access(in, true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
in.Token = ""
|
|
op, err = PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: initial.NamespaceID, CredentialID: initial.CredentialID, ExpectedRevision: in.ExpectedRevision, Action: "REVOKE", Reason: "revoke vault"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err = ApplyCredentialOperation(ctx, db, op, ring); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
in.ExpectedRevision++
|
|
if _, err := access(in, false); err == nil {
|
|
t.Fatal("revoked credential revealed")
|
|
}
|
|
var stored bool
|
|
if err := db.QueryRowContext(ctx, `SELECT secret_ciphertext IS NOT NULL FROM orbit_credentials WHERE namespace_id=? AND id=?`, initial.NamespaceID, initial.CredentialID).Scan(&stored); err != nil || stored {
|
|
t.Fatal("revoked ciphertext remains")
|
|
}
|
|
// 审计只包含版本与操作身份,不包含 Token 或密文。
|
|
rows, err := db.QueryContext(ctx, "SELECT CONCAT_WS(' ',action,`before`,`after`) FROM orbit_audit_events WHERE namespace_id=?", initial.NamespaceID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer rows.Close()
|
|
views := 0
|
|
for rows.Next() {
|
|
var text string
|
|
if err := rows.Scan(&text); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(text, initial.Token) || strings.Contains(text, rotate.Token) {
|
|
t.Fatal("secret leaked to audit")
|
|
}
|
|
if strings.Contains(text, "console.credential.token.view") {
|
|
views++
|
|
}
|
|
}
|
|
if rows.Err() != nil || views < 3 {
|
|
t.Fatal("view audit missing", rows.Err())
|
|
}
|
|
}
|