feat: let administrators view and copy encrypted integration credentials
This commit is contained in:
@@ -96,6 +96,7 @@ func TestIntegrationApplicationResourcesUpgrade(t *testing.T) {
|
||||
// 已经存在的 v29/v30 表误当成迁移重复执行。
|
||||
f.exec(`DROP TABLE orbit_archive_jobs,orbit_archive_plans,orbit_retention_policies,orbit_console_sessions,orbit_console_users`)
|
||||
f.exec(`ALTER TABLE orbit_credentials DROP CHECK ck_credential_overlap, DROP COLUMN previous_secret_digest, DROP COLUMN previous_valid_until`)
|
||||
f.exec(`ALTER TABLE orbit_credentials DROP CHECK ck_credential_ciphertext, DROP COLUMN secret_key_id, DROP COLUMN secret_ciphertext`)
|
||||
f.exec(`DELETE FROM orbit_schema_migrations WHERE version>=10`)
|
||||
if err := Migrate(ctx, db); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
-- Orbit fresh-install baseline, generated from migration version 32.
|
||||
-- Orbit fresh-install baseline, generated from migration version 33.
|
||||
-- This file intentionally contains no database-level relation constraints.
|
||||
CREATE TABLE `orbit_application_resources` (
|
||||
`application_id` binary(16) NOT NULL COMMENT '所属应用标识及主键,跨命名空间共享额度',
|
||||
@@ -59,6 +59,8 @@ CREATE TABLE `orbit_credentials` (
|
||||
`updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本',
|
||||
`name` varchar(128) COLLATE utf8mb4_0900_as_cs NOT NULL COMMENT '可读名称,唯一性范围由对应数据表约束',
|
||||
`secret_digest` binary(32) NOT NULL COMMENT '凭据秘密的 SHA-256 摘要,不保存或输出原文',
|
||||
`secret_key_id` varchar(64) CHARACTER SET ascii COLLATE ascii_bin DEFAULT NULL COMMENT '当前 Token 密文使用的主密钥标识;空表示尚未保管,主密钥不存数据库',
|
||||
`secret_ciphertext` varbinary(1024) DEFAULT NULL COMMENT '当前 Token 的 AES-256-GCM nonce 与认证密文;空表示旧凭据仅存摘要',
|
||||
`previous_secret_digest` binary(32) DEFAULT NULL COMMENT '上一代秘密的 SHA-256 摘要;为空表示无过渡令牌,不保存原文',
|
||||
`previous_valid_until` datetime(6) DEFAULT NULL COMMENT '上一代令牌过渡截止,UTC;不晚于其原到期时间,与旧摘要同时为空或非空',
|
||||
`permissions` json NOT NULL COMMENT '授权范围的 JSON 快照,必须由服务端解释和校验',
|
||||
@@ -67,8 +69,9 @@ CREATE TABLE `orbit_credentials` (
|
||||
PRIMARY KEY (`namespace_id`,`id`),
|
||||
UNIQUE KEY `uq_credential_name` (`namespace_id`,`name`),
|
||||
CONSTRAINT `ck_orbit_credentials_0` CHECK ((`revision` > 0)),
|
||||
CONSTRAINT `ck_credential_ciphertext` CHECK ((secret_key_id IS NULL AND secret_ciphertext IS NULL) OR (secret_key_id IS NOT NULL AND secret_ciphertext IS NOT NULL AND LENGTH(secret_key_id)>0 AND LENGTH(secret_ciphertext)>=28)),
|
||||
CONSTRAINT `ck_credential_overlap` CHECK ((previous_secret_digest IS NULL AND previous_valid_until IS NULL) OR (previous_secret_digest IS NOT NULL AND previous_valid_until IS NOT NULL))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='命名空间凭据,只保存秘密摘要和授权范围';
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='命名空间凭据、认证摘要、授权范围及可选的当前 Token 密文';
|
||||
CREATE TABLE `orbit_deliveries` (
|
||||
`namespace_id` binary(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验',
|
||||
`id` binary(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源',
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"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"
|
||||
@@ -49,7 +50,11 @@ type BootstrapResult struct {
|
||||
ConsoleUsername string `json:"console_username,omitempty"` // ConsoleUsername 可选的首个控制台账号,不包含密码。
|
||||
}
|
||||
|
||||
func Bootstrap(ctx context.Context, db *sql.DB, in BootstrapInput, deliver func(BootstrapResult) error) error {
|
||||
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")
|
||||
}
|
||||
@@ -96,6 +101,10 @@ func Bootstrap(ctx context.Context, db *sql.DB, in BootstrapInput, deliver func(
|
||||
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")
|
||||
@@ -180,7 +189,7 @@ func Bootstrap(ctx context.Context, db *sql.DB, in BootstrapInput, deliver func(
|
||||
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) VALUES (?,?,'bootstrap',?,?,?)", out.NamespaceID, out.CredentialID, digest, string(raw), out.ExpiresAt); err != nil {
|
||||
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)
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"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"
|
||||
@@ -21,10 +22,15 @@ import (
|
||||
type ConsoleApplicationStore struct {
|
||||
db *sql.DB
|
||||
username string
|
||||
vault *credentialvault.Keyring
|
||||
}
|
||||
|
||||
func NewConsoleApplicationStore(db *sql.DB, username string) *ConsoleApplicationStore {
|
||||
return &ConsoleApplicationStore{db: db, username: username}
|
||||
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)
|
||||
@@ -32,7 +38,7 @@ 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 == "" {
|
||||
if s.username == "" || token.NamespaceID.Validate() != nil || token.CredentialID.Validate() != nil {
|
||||
return "", fault.Forbidden
|
||||
}
|
||||
var user core.ID
|
||||
@@ -117,7 +123,8 @@ func (s *ConsoleApplicationStore) GetApplication(ctx context.Context, token auth
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err = tx.QueryContext(ctx, `SELECT id,name,revision,expires_at,revoked_at,previous_valid_until,permissions FROM orbit_credentials WHERE namespace_id=? ORDER BY id LIMIT 101`, namespace)
|
||||
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
|
||||
}
|
||||
@@ -126,7 +133,7 @@ func (s *ConsoleApplicationStore) GetApplication(ctx context.Context, token auth
|
||||
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); err != nil {
|
||||
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 {
|
||||
@@ -299,7 +306,7 @@ func (s *ConsoleApplicationStore) ApplyApplication(ctx context.Context, token au
|
||||
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)
|
||||
@@ -352,6 +359,10 @@ func (s *ConsoleApplicationStore) ApplyApplication(ctx context.Context, token au
|
||||
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
|
||||
@@ -360,7 +371,7 @@ func (s *ConsoleApplicationStore) ApplyApplication(ctx context.Context, token au
|
||||
{`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}},
|
||||
{`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 {
|
||||
|
||||
85
internal/storage/mysql/console_credential_tokens.go
Normal file
85
internal/storage/mysql/console_credential_tokens.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
var _ consoleauth.CredentialTokens = (*ConsoleApplicationStore)(nil)
|
||||
|
||||
// AccessCredentialToken 认证、版本核对、密文读取/补存和审计在同一事务完成。
|
||||
// 导入相同当前秘密可重复调用,不变更认证 revision、到期时间或旧代过渡截止。
|
||||
func (s *ConsoleApplicationStore) AccessCredentialToken(ctx context.Context, session auth.Token, namespace, credential core.ID, in consoleauth.CredentialTokenInput, importCurrent bool) (consoleauth.CredentialToken, error) {
|
||||
var out consoleauth.CredentialToken
|
||||
if namespace.Validate() != nil || credential.Validate() != nil || in.ExpectedRevision == 0 || (!importCurrent && in.Token != "") {
|
||||
return out, fault.Invalid("invalid credential token request")
|
||||
}
|
||||
err := WithTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
actor, err := s.admin(ctx, tx, session)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if s.vault == nil {
|
||||
return fault.New("UNAVAILABLE", "Token 保管密钥尚未配置,请联系部署管理员")
|
||||
}
|
||||
var revision uint64
|
||||
var revoked sql.NullTime
|
||||
var digest core.Digest
|
||||
var keyID sql.NullString
|
||||
var ciphertext []byte
|
||||
err = tx.QueryRowContext(ctx, `SELECT revision,revoked_at,secret_digest,secret_key_id,secret_ciphertext FROM orbit_credentials WHERE namespace_id=? AND id=? FOR UPDATE`, namespace, credential).Scan(&revision, &revoked, &digest, &keyID, &ciphertext)
|
||||
if err == sql.ErrNoRows {
|
||||
return fault.NotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if revision != in.ExpectedRevision || revoked.Valid {
|
||||
return fault.New("STATE_CONFLICT", "凭据已变更或撤销,请刷新后重试")
|
||||
}
|
||||
raw := in.Token
|
||||
action := "console.credential.token.view"
|
||||
if !importCurrent {
|
||||
if !keyID.Valid {
|
||||
return fault.New("STATE_CONFLICT", "此历史凭据尚未保管 Token,请先导入当前 Token")
|
||||
}
|
||||
raw, err = s.vault.Open(namespace, credential, keyID.String, ciphertext)
|
||||
if err != nil {
|
||||
return fault.New("UNAVAILABLE", "Token 解密失败,请检查保管密钥文件")
|
||||
}
|
||||
}
|
||||
parsed, err := auth.Parse(raw)
|
||||
if err != nil || parsed.NamespaceID != namespace || parsed.CredentialID != credential || subtleCompareDigest(parsed.Digest, digest) != 1 {
|
||||
return fault.Invalid("Token 与当前凭据不匹配;不能导入过渡期旧 Token")
|
||||
}
|
||||
if importCurrent {
|
||||
action = "console.credential.token.import"
|
||||
id, encrypted, err := s.vault.Seal(namespace, credential, raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE orbit_credentials SET secret_key_id=?,secret_ciphertext=?,updated_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND id=?`, id, encrypted, namespace, credential); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
operation, err := core.NewID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := appendAuditActor(ctx, tx, namespace, actor, operation, action, credential, "administrator access to current integration configuration", nil, map[string]any{"revision": revision}); err != nil {
|
||||
return err
|
||||
}
|
||||
out = consoleauth.CredentialToken{NamespaceID: namespace, CredentialID: credential, Revision: revision, Token: raw}
|
||||
return nil
|
||||
})
|
||||
// 包括提交结果未知:只有确认审计提交成功,才允许向调用方交付秘密。
|
||||
if err != nil {
|
||||
return consoleauth.CredentialToken{}, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
207
internal/storage/mysql/credential_vault_integration_test.go
Normal file
207
internal/storage/mysql/credential_vault_integration_test.go
Normal file
@@ -0,0 +1,207 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"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"
|
||||
@@ -67,7 +68,7 @@ type CredentialChangeInput struct {
|
||||
}
|
||||
|
||||
// CredentialOperation 是可安全重放的私有操作文件内容。
|
||||
// 令牌只存在于 ROTATE 文件中;服务端只保存其摘要和操作回执。
|
||||
// ROTATE 文件包含新令牌;服务端保存摘要、可选密文和不含秘密的操作回执。
|
||||
type CredentialOperation struct {
|
||||
// Version 操作文件格式版本,升级时不得静默改变语义。
|
||||
Version int `json:"version"`
|
||||
@@ -225,12 +226,16 @@ func ValidateCredentialOperation(op CredentialOperation) error {
|
||||
// ApplyCredentialOperation applies one frozen operation or returns its original
|
||||
// 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)
|
||||
func ApplyCredentialOperation(ctx context.Context, db *sql.DB, op CredentialOperation, vault ...*credentialvault.Keyring) (CredentialChangeReceipt, error) {
|
||||
return applyCredentialOperation(ctx, db, op, nil, vault...)
|
||||
}
|
||||
|
||||
// applyCredentialOperation 允许控制台在同一事务先核验管理员,并使用真实人类审计主体。
|
||||
func applyCredentialOperation(ctx context.Context, db *sql.DB, op CredentialOperation, authorize func(*sql.Tx) (string, error)) (CredentialChangeReceipt, error) {
|
||||
func applyCredentialOperation(ctx context.Context, db *sql.DB, op CredentialOperation, authorize func(*sql.Tx) (string, error), vault ...*credentialvault.Keyring) (CredentialChangeReceipt, error) {
|
||||
var ring *credentialvault.Keyring
|
||||
if len(vault) > 0 {
|
||||
ring = vault[0]
|
||||
}
|
||||
var out CredentialChangeReceipt
|
||||
if err := ValidateCredentialOperation(op); err != nil {
|
||||
return out, err
|
||||
@@ -320,9 +325,14 @@ func applyCredentialOperation(ctx context.Context, db *sql.DB, op CredentialOper
|
||||
out.PreviousValidUntil = timePtr(until)
|
||||
}
|
||||
}
|
||||
keyID, ciphertext, err := ring.Seal(op.NamespaceID, op.CredentialID, op.Token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 未配置保管密钥的旧 CLI 轮换也必须清除旧密文,不能回看过时代次。
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE orbit_credentials
|
||||
SET secret_digest=?,expires_at=?,previous_secret_digest=?,previous_valid_until=?,revision=revision+1,updated_at=?
|
||||
WHERE namespace_id=? AND id=? AND revision=?`, parsed.Digest, op.ExpiresAt, previousDigest, out.PreviousValidUntil, now, op.NamespaceID, op.CredentialID, revision); err != nil {
|
||||
SET secret_digest=?,expires_at=?,previous_secret_digest=?,previous_valid_until=?,secret_key_id=?,secret_ciphertext=?,revision=revision+1,updated_at=?
|
||||
WHERE namespace_id=? AND id=? AND revision=?`, parsed.Digest, op.ExpiresAt, previousDigest, out.PreviousValidUntil, keyID, ciphertext, now, op.NamespaceID, op.CredentialID, revision); err != nil {
|
||||
return err
|
||||
}
|
||||
out.ExpiresAt = op.ExpiresAt
|
||||
@@ -341,7 +351,7 @@ func applyCredentialOperation(ctx context.Context, db *sql.DB, op CredentialOper
|
||||
}
|
||||
out.PreviousValidUntil = nil
|
||||
case credentialRevokeAction:
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE orbit_credentials SET revoked_at=?,previous_secret_digest=NULL,previous_valid_until=NULL,revision=revision+1,updated_at=? WHERE namespace_id=? AND id=? AND revision=?`, now, now, op.NamespaceID, op.CredentialID, revision); err != nil {
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE orbit_credentials SET revoked_at=?,previous_secret_digest=NULL,previous_valid_until=NULL,secret_key_id=NULL,secret_ciphertext=NULL,revision=revision+1,updated_at=? WHERE namespace_id=? AND id=? AND revision=?`, now, now, op.NamespaceID, op.CredentialID, revision); err != nil {
|
||||
return err
|
||||
}
|
||||
out.RevokedAt = timePtr(now)
|
||||
|
||||
@@ -26,7 +26,7 @@ var migrationFiles embed.FS
|
||||
//go:embed baseline.sql
|
||||
var freshInstallBaseline string
|
||||
|
||||
const freshInstallBaselineVersion uint64 = 32
|
||||
const freshInstallBaselineVersion uint64 = 33
|
||||
|
||||
type Migration struct {
|
||||
Version uint64
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- 管理员可回看当前 Token;旧记录保留为空,导入只补密文,不轮换秘密。
|
||||
ALTER TABLE orbit_credentials
|
||||
ADD COLUMN secret_key_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NULL COMMENT '当前 Token 密文使用的主密钥标识;空表示尚未保管,主密钥不存数据库',
|
||||
ADD COLUMN secret_ciphertext VARBINARY(1024) NULL COMMENT '当前 Token 的 AES-256-GCM nonce 与认证密文;空表示旧凭据仅存摘要',
|
||||
ADD CONSTRAINT ck_credential_ciphertext CHECK ((secret_key_id IS NULL AND secret_ciphertext IS NULL) OR (secret_key_id IS NOT NULL AND secret_ciphertext IS NOT NULL AND LENGTH(secret_key_id)>0 AND LENGTH(secret_ciphertext)>=28)),
|
||||
COMMENT='命名空间凭据、认证摘要、授权范围及可选的当前 Token 密文';
|
||||
Reference in New Issue
Block a user