feat: make credential changes replayable
This commit is contained in:
@@ -154,7 +154,11 @@ func Bootstrap(ctx context.Context, db *sql.DB, in BootstrapInput, deliver func(
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := appendAudit(ctx, tx, auth.Token{NamespaceID: out.NamespaceID, CredentialID: out.CredentialID}, op, out.CredentialID, "credential", "bootstrap.create", "database administrator bootstrap", nil, map[string]any{"permissions": permissions, "expires_at": out.ExpiresAt}); err != nil {
|
||||
actor, err := databaseActor(ctx, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := appendAuditActor(ctx, tx, out.NamespaceID, actor, op, "bootstrap.create", out.CredentialID, "database administrator bootstrap", nil, map[string]any{"permissions": permissions, "expires_at": out.ExpiresAt}); err != nil {
|
||||
return err
|
||||
}
|
||||
// 交付文件先持久化再提交;提交失败时保留文件以便核对不确定结果。
|
||||
|
||||
195
internal/storage/mysql/credential_integration_test.go
Normal file
195
internal/storage/mysql/credential_integration_test.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
)
|
||||
|
||||
// TestIntegrationCredentialOperations verifies the private operation protocol:
|
||||
// one frozen token, stable idempotency and audit identity from MySQL itself.
|
||||
func TestIntegrationCredentialOperations(t *testing.T) {
|
||||
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set ORBIT_TEST_MYSQL_DSN to a disposable database, or run make integration")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
db, err := Open(ctx, dsn, 8, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if err := Migrate(ctx, db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var initial BootstrapResult
|
||||
name := "credential-" + string(testID(t))
|
||||
err = Bootstrap(ctx, db, BootstrapInput{Application: name, Namespace: "default", Environment: "test", Queue: "credential"}, func(v BootstrapResult) error {
|
||||
initial = v
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status, err := GetCredentialStatus(ctx, db, initial.NamespaceID, initial.CredentialID)
|
||||
if err != nil || status.Revision != 1 || status.RevokedAt != nil {
|
||||
t.Fatalf("initial credential status: %+v %v", status, err)
|
||||
}
|
||||
old, err := auth.Parse(initial.Token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := NewTaskStore(db).AuthorizeSubmit(ctx, old); err != nil {
|
||||
t.Fatalf("initial credential rejected: %v", err)
|
||||
}
|
||||
|
||||
op, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{
|
||||
NamespaceID: initial.NamespaceID, CredentialID: initial.CredentialID, ExpectedRevision: 1,
|
||||
Action: credentialRotateAction, ValidFor: 2 * time.Hour, Reason: "scheduled rotation",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if op.Token == initial.Token || op.OperationID == "" || op.ExpiresAt == nil {
|
||||
t.Fatal("prepare did not freeze a new token and expiry")
|
||||
}
|
||||
receipt, err := ApplyCredentialOperation(ctx, db, op)
|
||||
if err != nil || receipt.Revision != 2 || receipt.Deduplicated {
|
||||
t.Fatalf("rotation receipt: %+v %v", receipt, err)
|
||||
}
|
||||
newToken, err := auth.Parse(op.Token)
|
||||
if err != nil || NewTaskStore(db).AuthorizeSubmit(ctx, old) == nil || NewTaskStore(db).AuthorizeSubmit(ctx, newToken) != nil {
|
||||
t.Fatalf("credential authentication boundary failed: %v", err)
|
||||
}
|
||||
|
||||
replayed, err := ApplyCredentialOperation(ctx, db, op)
|
||||
if err != nil || !replayed.Deduplicated || replayed.OperationID != receipt.OperationID || replayed.Revision != receipt.Revision {
|
||||
t.Fatalf("stable operation replay: %+v %v", replayed, err)
|
||||
}
|
||||
changed := op
|
||||
changed.Token, _, err = auth.Issue(initial.NamespaceID, initial.CredentialID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ApplyCredentialOperation(ctx, db, changed); fault.Code(err) != "IDEMPOTENCY_CONFLICT" {
|
||||
t.Fatalf("changed operation file was accepted: %v", err)
|
||||
}
|
||||
|
||||
// Revocation remains available for a disabled application so an incident can
|
||||
// close credentials even when ordinary authentication is already unavailable.
|
||||
if _, err := db.ExecContext(ctx, "UPDATE orbit_applications SET enabled=FALSE WHERE id=?", initial.ApplicationID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
revoke, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{
|
||||
NamespaceID: initial.NamespaceID, CredentialID: initial.CredentialID, ExpectedRevision: 2,
|
||||
Action: credentialRevokeAction, Reason: "incident response",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
revoked, err := ApplyCredentialOperation(ctx, db, revoke)
|
||||
if err != nil || revoked.Revision != 3 || revoked.RevokedAt == nil {
|
||||
t.Fatalf("revoke receipt: %+v %v", revoked, err)
|
||||
}
|
||||
if _, err := ApplyCredentialOperation(ctx, db, revoke); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := NewTaskStore(db).AuthorizeSubmit(ctx, newToken); fault.Code(err) != "UNAUTHENTICATED" {
|
||||
t.Fatalf("revoked token remained usable: %v", err)
|
||||
}
|
||||
|
||||
var audits int
|
||||
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM orbit_audit_events WHERE namespace_id=? AND resource_id=? AND action IN ('credential.rotate','credential.revoke')`, initial.NamespaceID, initial.CredentialID).Scan(&audits); err != nil || audits != 2 {
|
||||
t.Fatalf("credential audit count=%d err=%v", audits, err)
|
||||
}
|
||||
var actor, before, after string
|
||||
if err := db.QueryRowContext(ctx, `SELECT actor_id,`+"`before`"+`,`+"`after`"+` FROM orbit_audit_events WHERE namespace_id=? AND operation_id=?`, initial.NamespaceID, op.OperationID).Scan(&actor, &before, &after); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(actor, "mysql:") || actor == string(initial.CredentialID) || strings.Contains(before+after, op.Token) || strings.Contains(before+after, "secret_digest") {
|
||||
t.Fatalf("audit leaked or misidentified operator: actor=%q before=%s after=%s", actor, before, after)
|
||||
}
|
||||
var stored []byte
|
||||
if err := db.QueryRowContext(ctx, `SELECT response FROM orbit_idempotency_keys WHERE namespace_id=? AND id=?`, initial.NamespaceID, op.OperationID).Scan(&stored); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var saved CredentialChangeReceipt
|
||||
if err := json.Unmarshal(stored, &saved); err != nil || saved.OperationID != op.OperationID {
|
||||
t.Fatalf("stored receipt: %s %v", stored, err)
|
||||
}
|
||||
t.Log("credential operation file froze one token, replay returned the original receipt, tampering conflicted, and disabled-scope revocation preserved audit identity")
|
||||
}
|
||||
|
||||
func TestIntegrationCredentialCommitFaults(t *testing.T) {
|
||||
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set ORBIT_TEST_MYSQL_DSN to a disposable database, or run make integration")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
db, err := Open(ctx, dsn, 8, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if err := Migrate(ctx, db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, after := range []bool{false, true} {
|
||||
name := "credential-commit-" + string(testID(t))
|
||||
var initial BootstrapResult
|
||||
if err := Bootstrap(ctx, db, BootstrapInput{Application: name, Namespace: "default", Environment: "test", Queue: "credential"}, func(v BootstrapResult) error { initial = v; return nil }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
op, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: initial.NamespaceID, CredentialID: initial.CredentialID, ExpectedRevision: 1, Action: credentialRotateAction, ValidFor: time.Hour, Reason: "commit boundary"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
proxy, proxiedDSN := newCommitFaultProxy(t, dsn)
|
||||
faultDB, err := Open(ctx, proxiedDSN, 2, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after {
|
||||
proxy.mode.Store(2)
|
||||
} else {
|
||||
proxy.mode.Store(1)
|
||||
}
|
||||
_, applyErr := ApplyCredentialOperation(ctx, faultDB, op)
|
||||
proxy.expect(t, after, applyErr)
|
||||
faultDB.Close()
|
||||
status, err := GetCredentialStatus(ctx, db, initial.NamespaceID, initial.CredentialID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantRevision := uint64(1)
|
||||
if after {
|
||||
wantRevision = 2
|
||||
}
|
||||
if status.Revision != wantRevision {
|
||||
t.Fatalf("commit %v status revision=%d want=%d", after, status.Revision, wantRevision)
|
||||
}
|
||||
receipt, err := ApplyCredentialOperation(ctx, db, op)
|
||||
if err != nil || receipt.Revision != 2 || receipt.Deduplicated != after {
|
||||
t.Fatalf("commit %v reconciliation receipt=%+v err=%v", after, receipt, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testID(t *testing.T) core.ID {
|
||||
t.Helper()
|
||||
id, err := core.NewID()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
431
internal/storage/mysql/credentials.go
Normal file
431
internal/storage/mysql/credentials.go
Normal file
@@ -0,0 +1,431 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
)
|
||||
|
||||
const (
|
||||
credentialOperationVersion = 1
|
||||
credentialRotateAction = "ROTATE"
|
||||
credentialRevokeAction = "REVOKE"
|
||||
credentialOperationName = "credential.change.v1"
|
||||
)
|
||||
|
||||
// CredentialStatus 是管理读取的脱敏凭据状态;永远不包含秘密或摘要。
|
||||
// Enabled 字段分别表示所属命名空间和应用当前是否允许业务认证。
|
||||
type CredentialStatus struct {
|
||||
// NamespaceID 凭据所属命名空间标识。
|
||||
NamespaceID core.ID `json:"namespace_id"`
|
||||
// CredentialID 凭据记录标识,轮换时保持不变。
|
||||
CredentialID core.ID `json:"credential_id"`
|
||||
// Name 凭据的管理名称。
|
||||
Name string `json:"name"`
|
||||
// Revision 凭据的乐观并发版本,所有变更都必须核对它。
|
||||
Revision uint64 `json:"revision"`
|
||||
// ExpiresAt 当前秘密的到期时间;空值表示未设置到期时间。
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
// RevokedAt 撤销时间;空值表示尚未撤销。
|
||||
RevokedAt *time.Time `json:"revoked_at,omitempty"`
|
||||
// NamespaceEnabled 命名空间是否启用。
|
||||
NamespaceEnabled bool `json:"namespace_enabled"`
|
||||
// ApplicationEnabled 所属应用是否启用。
|
||||
ApplicationEnabled bool `json:"application_enabled"`
|
||||
}
|
||||
|
||||
// CredentialChangeInput 描述一次待固定的管理变更。
|
||||
// ExpectedRevision 防止准备和提交之间的并发变更被覆盖。
|
||||
type CredentialChangeInput struct {
|
||||
// NamespaceID 凭据所属命名空间。
|
||||
NamespaceID core.ID
|
||||
// CredentialID 要变更的凭据。
|
||||
CredentialID core.ID
|
||||
// ExpectedRevision 准备操作时观察到的版本。
|
||||
ExpectedRevision uint64
|
||||
// Action 只能是 ROTATE 或 REVOKE。
|
||||
Action string
|
||||
// ValidFor 轮换后的有效时长;撤销时必须为零。
|
||||
ValidFor time.Duration
|
||||
// Reason 必填审计原因。
|
||||
Reason string
|
||||
}
|
||||
|
||||
// CredentialOperation 是可安全重放的私有操作文件内容。
|
||||
// 令牌只存在于 ROTATE 文件中;服务端只保存其摘要和操作回执。
|
||||
type CredentialOperation struct {
|
||||
// Version 操作文件格式版本,升级时不得静默改变语义。
|
||||
Version int `json:"version"`
|
||||
// OperationID 稳定操作身份,同时作为审计和幂等记录的身份。
|
||||
OperationID core.ID `json:"operation_id"`
|
||||
// NamespaceID 凭据所属命名空间。
|
||||
NamespaceID core.ID `json:"namespace_id"`
|
||||
// CredentialID 变更目标凭据。
|
||||
CredentialID core.ID `json:"credential_id"`
|
||||
// ExpectedRevision 只允许在该版本上首次应用。
|
||||
ExpectedRevision uint64 `json:"expected_revision"`
|
||||
// Action 固定为 ROTATE 或 REVOKE。
|
||||
Action string `json:"action"`
|
||||
// Reason 写入管理审计,不能包含控制字符或秘密。
|
||||
Reason string `json:"reason"`
|
||||
// Token 轮换产生的一次性新令牌;撤销操作为空。
|
||||
Token string `json:"token,omitempty"`
|
||||
// ExpiresAt 轮换后固定的绝对到期时间;重放不能重新计算。
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
// CredentialChangeReceipt 是服务端保存并可重复返回的脱敏结果。
|
||||
// Deduplicated 只表示本次调用命中了已有回执,不表示当前凭据仍处于该状态。
|
||||
type CredentialChangeReceipt struct {
|
||||
// OperationID 稳定操作身份。
|
||||
OperationID core.ID `json:"operation_id"`
|
||||
// NamespaceID 操作所属命名空间。
|
||||
NamespaceID core.ID `json:"namespace_id"`
|
||||
// CredentialID 变更目标凭据。
|
||||
CredentialID core.ID `json:"credential_id"`
|
||||
// Action 已应用的变更类型。
|
||||
Action string `json:"action"`
|
||||
// Revision 该操作提交后凭据版本。
|
||||
Revision uint64 `json:"revision"`
|
||||
// AppliedAt 操作提交事务使用的数据库时间。
|
||||
AppliedAt time.Time `json:"applied_at"`
|
||||
// ExpiresAt 轮换后的固定到期时间。
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
// RevokedAt 撤销提交时间。
|
||||
RevokedAt *time.Time `json:"revoked_at,omitempty"`
|
||||
// Deduplicated 当前调用是否读取了既有回执。
|
||||
Deduplicated bool `json:"deduplicated"`
|
||||
}
|
||||
|
||||
// GetCredentialStatus 读取脱敏状态,不需要使用该凭据认证。
|
||||
func GetCredentialStatus(ctx context.Context, db *sql.DB, namespace, credential core.ID) (CredentialStatus, error) {
|
||||
var out CredentialStatus
|
||||
if err := validateCredentialIDs(namespace, credential); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err := CheckSchema(ctx, db); err != nil {
|
||||
return out, err
|
||||
}
|
||||
var expires, revoked sql.NullTime
|
||||
err := db.QueryRowContext(ctx, `SELECT c.name,c.revision,c.expires_at,c.revoked_at,n.enabled,a.enabled
|
||||
FROM orbit_credentials c
|
||||
JOIN orbit_namespaces n ON n.id=c.namespace_id
|
||||
JOIN orbit_applications a ON a.id=n.application_id
|
||||
WHERE c.namespace_id=? AND c.id=?`, namespace, credential).
|
||||
Scan(&out.Name, &out.Revision, &expires, &revoked, &out.NamespaceEnabled, &out.ApplicationEnabled)
|
||||
if err == sql.ErrNoRows {
|
||||
return out, fault.NotFound
|
||||
}
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.NamespaceID, out.CredentialID = namespace, credential
|
||||
out.ExpiresAt = nullableTime(expires)
|
||||
out.RevokedAt = nullableTime(revoked)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// PrepareCredentialOperation 只读取当前状态并生成一次秘密,不修改数据库。
|
||||
// 调用方应先把返回值写入受保护的新文件,再调用 ApplyCredentialOperation。
|
||||
func PrepareCredentialOperation(ctx context.Context, db *sql.DB, in CredentialChangeInput) (CredentialOperation, error) {
|
||||
var out CredentialOperation
|
||||
if err := validateCredentialChangeInput(in); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err := CheckSchema(ctx, db); err != nil {
|
||||
return out, err
|
||||
}
|
||||
status, err := GetCredentialStatus(ctx, db, in.NamespaceID, in.CredentialID)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if status.Revision != in.ExpectedRevision || status.RevokedAt != nil {
|
||||
return out, fault.Conflict
|
||||
}
|
||||
var now time.Time
|
||||
if err := db.QueryRowContext(ctx, "SELECT UTC_TIMESTAMP(6)").Scan(&now); err != nil {
|
||||
return out, err
|
||||
}
|
||||
now = now.UTC()
|
||||
if in.Action == credentialRotateAction {
|
||||
secret, _, err := auth.Issue(in.NamespaceID, in.CredentialID)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.Token = secret
|
||||
out.ExpiresAt = timePtr(now.Add(in.ValidFor))
|
||||
} else if in.ValidFor != 0 {
|
||||
return out, fault.Invalid("revoke operation cannot set validity")
|
||||
}
|
||||
out.Version = credentialOperationVersion
|
||||
out.NamespaceID = in.NamespaceID
|
||||
out.CredentialID = in.CredentialID
|
||||
out.ExpectedRevision = in.ExpectedRevision
|
||||
out.Action = in.Action
|
||||
out.Reason = in.Reason
|
||||
out.OperationID, err = core.NewID()
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
return out, ValidateCredentialOperation(out)
|
||||
}
|
||||
|
||||
// ValidateCredentialOperation validates the exact private operation shape.
|
||||
// It is exported so the admin CLI can reject tampered files before opening a DB.
|
||||
func ValidateCredentialOperation(op CredentialOperation) error {
|
||||
if op.Version != credentialOperationVersion || op.OperationID.Validate() != nil || op.NamespaceID.Validate() != nil || op.CredentialID.Validate() != nil {
|
||||
return fault.Invalid("credential operation identity or version is invalid")
|
||||
}
|
||||
if op.ExpectedRevision == 0 || (op.Action != credentialRotateAction && op.Action != credentialRevokeAction) {
|
||||
return fault.Invalid("credential operation action or revision is invalid")
|
||||
}
|
||||
if err := validCredentialReason(op.Reason); err != nil {
|
||||
return err
|
||||
}
|
||||
if op.Action == credentialRotateAction {
|
||||
if op.ExpiresAt == nil || op.ExpiresAt.IsZero() || op.Token == "" {
|
||||
return fault.Invalid("rotation operation must contain a token and fixed expiry")
|
||||
}
|
||||
parsed, err := auth.Parse(op.Token)
|
||||
if err != nil || parsed.NamespaceID != op.NamespaceID || parsed.CredentialID != op.CredentialID {
|
||||
return fault.Invalid("rotation token does not match credential identity")
|
||||
}
|
||||
} else if op.Token != "" || op.ExpiresAt != nil {
|
||||
return fault.Invalid("revoke operation must not contain a token or expiry")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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) {
|
||||
var out CredentialChangeReceipt
|
||||
if err := ValidateCredentialOperation(op); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err := CheckSchema(ctx, db); err != nil {
|
||||
return out, err
|
||||
}
|
||||
err := WithTx(ctx, db, func(tx *sql.Tx) error {
|
||||
var name string
|
||||
var revision uint64
|
||||
var expires, revoked sql.NullTime
|
||||
var namespaceEnabled, applicationEnabled bool
|
||||
err := tx.QueryRowContext(ctx, `SELECT c.name,c.revision,c.expires_at,c.revoked_at,n.enabled,a.enabled
|
||||
FROM orbit_credentials c
|
||||
JOIN orbit_namespaces n ON n.id=c.namespace_id
|
||||
JOIN orbit_applications a ON a.id=n.application_id
|
||||
WHERE c.namespace_id=? AND c.id=? FOR UPDATE`, op.NamespaceID, op.CredentialID).
|
||||
Scan(&name, &revision, &expires, &revoked, &namespaceEnabled, &applicationEnabled)
|
||||
if err == sql.ErrNoRows {
|
||||
return fault.NotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
identity, cached, err := reserveCredentialOperation(ctx, tx, op)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cached != nil {
|
||||
if err := json.Unmarshal(cached, &out); err != nil {
|
||||
return fmt.Errorf("stored credential receipt is invalid: %w", err)
|
||||
}
|
||||
out.Deduplicated = true
|
||||
return nil
|
||||
}
|
||||
if revision != op.ExpectedRevision || revoked.Valid {
|
||||
return fault.Conflict
|
||||
}
|
||||
if revision == ^uint64(0) {
|
||||
return fault.Conflict
|
||||
}
|
||||
now, err := dbNow(ctx, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
before := map[string]any{"name": name, "revision": revision, "expires_at": nullableTime(expires), "revoked_at": nullableTime(revoked), "namespace_enabled": namespaceEnabled, "application_enabled": applicationEnabled}
|
||||
after := map[string]any{"name": name, "revision": revision + 1, "namespace_enabled": namespaceEnabled, "application_enabled": applicationEnabled}
|
||||
if op.Action == credentialRotateAction {
|
||||
parsed, err := auth.Parse(op.Token)
|
||||
if err != nil {
|
||||
return fault.Invalid("rotation token is invalid")
|
||||
}
|
||||
if !op.ExpiresAt.After(now) || op.ExpiresAt.After(now.Add(365*24*time.Hour)) {
|
||||
return fault.Conflict
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE orbit_credentials
|
||||
SET secret_digest=?,expires_at=?,revoked_at=NULL,revision=revision+1,updated_at=?
|
||||
WHERE namespace_id=? AND id=? AND revision=?`, parsed.Digest, op.ExpiresAt.UTC(), now, op.NamespaceID, op.CredentialID, revision); err != nil {
|
||||
return err
|
||||
}
|
||||
out.ExpiresAt = timePtr(op.ExpiresAt.UTC())
|
||||
after["expires_at"] = out.ExpiresAt
|
||||
} else {
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE orbit_credentials
|
||||
SET revoked_at=?,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)
|
||||
after["revoked_at"] = now
|
||||
}
|
||||
out.OperationID = op.OperationID
|
||||
out.NamespaceID = op.NamespaceID
|
||||
out.CredentialID = op.CredentialID
|
||||
out.Action = op.Action
|
||||
out.Revision = revision + 1
|
||||
out.AppliedAt = now
|
||||
actor, err := databaseActor(ctx, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := appendCredentialAudit(ctx, tx, actor, op, before, after); err != nil {
|
||||
return err
|
||||
}
|
||||
encoded, err := json.Marshal(out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE orbit_idempotency_keys SET response=?,revision=revision+1,updated_at=? WHERE namespace_id=? AND id=?`, encoded, now, op.NamespaceID, identity); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// reserveCredentialOperation stores the frozen request digest before mutation.
|
||||
// The operation ID is also the idempotency row ID, so retries never mint a new
|
||||
// secret or a second audit event.
|
||||
func reserveCredentialOperation(ctx context.Context, tx *sql.Tx, op CredentialOperation) (core.ID, []byte, error) {
|
||||
digest, err := requestDigest(op)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
keyDigest := core.Digest(sha256.Sum256([]byte(op.OperationID)))
|
||||
_, 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 (?,?,?,?,?,?,?,?,JSON_OBJECT(),DATE_ADD(UTC_TIMESTAMP(6),INTERVAL 90 DAY))
|
||||
ON DUPLICATE KEY UPDATE id=orbit_idempotency_keys.id`, op.NamespaceID, op.OperationID, credentialOperationName, string(op.CredentialID), keyDigest, digest, "credential", op.CredentialID)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
var storedID core.ID
|
||||
var storedDigest core.Digest
|
||||
var response []byte
|
||||
if err := tx.QueryRowContext(ctx, `SELECT id,request_digest,response FROM orbit_idempotency_keys
|
||||
WHERE namespace_id=? AND operation=? AND scope_key=? AND key_digest=? FOR UPDATE`, op.NamespaceID, credentialOperationName, string(op.CredentialID), keyDigest).
|
||||
Scan(&storedID, &storedDigest, &response); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if subtleCompareDigest(digest, storedDigest) != 1 {
|
||||
return "", nil, fault.IdempotencyConflict
|
||||
}
|
||||
trimmed := strings.TrimSpace(string(response))
|
||||
if trimmed != "" && trimmed != "{}" && trimmed != "null" {
|
||||
return storedID, response, nil
|
||||
}
|
||||
return storedID, nil, nil
|
||||
}
|
||||
|
||||
func appendCredentialAudit(ctx context.Context, tx *sql.Tx, actor string, op CredentialOperation, before, after any) error {
|
||||
return appendAuditActor(ctx, tx, op.NamespaceID, actor, op.OperationID, strings.ToLower("credential."+strings.ToLower(op.Action)), op.CredentialID, op.Reason, before, after)
|
||||
}
|
||||
|
||||
func appendAuditActor(ctx context.Context, tx *sql.Tx, namespace core.ID, actor string, operation core.ID, action string, resource core.ID, reason string, before, after any) error {
|
||||
old, err := json.Marshal(before)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
current, err := json.Marshal(after)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := core.NewID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO orbit_audit_events(namespace_id,id,operation_id,actor_id,action,resource_kind,resource_id,reason,evidence_ref,`+"`before`"+`,`+"`after`"+`)
|
||||
VALUES (?,?,?,?,?,?,?,?,NULL,?,?)`, namespace, id, operation, actor, action, "credential", resource, reason, string(old), string(current))
|
||||
return err
|
||||
}
|
||||
|
||||
func databaseActor(ctx context.Context, tx *sql.Tx) (string, error) {
|
||||
var principal string
|
||||
if err := tx.QueryRowContext(ctx, "SELECT CURRENT_USER()").Scan(&principal); err != nil {
|
||||
return "", err
|
||||
}
|
||||
principal = "mysql:" + principal
|
||||
if len(principal) <= 128 {
|
||||
return principal, nil
|
||||
}
|
||||
sum := sha256.Sum256([]byte(principal))
|
||||
return fmt.Sprintf("mysql-sha256:%x", sum[:]), nil
|
||||
}
|
||||
|
||||
func subtleCompareDigest(a, b core.Digest) int {
|
||||
var result byte
|
||||
for i := range a {
|
||||
result |= a[i] ^ b[i]
|
||||
}
|
||||
if result == 0 {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func validateCredentialIDs(namespace, credential core.ID) error {
|
||||
if namespace.Validate() != nil || credential.Validate() != nil {
|
||||
return fault.Invalid("credential identity is invalid")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCredentialChangeInput(in CredentialChangeInput) error {
|
||||
if err := validateCredentialIDs(in.NamespaceID, in.CredentialID); err != nil {
|
||||
return err
|
||||
}
|
||||
if in.ExpectedRevision == 0 || (in.Action != credentialRotateAction && in.Action != credentialRevokeAction) {
|
||||
return fault.Invalid("credential action and expected revision are required")
|
||||
}
|
||||
if err := validCredentialReason(in.Reason); err != nil {
|
||||
return err
|
||||
}
|
||||
if in.Action == credentialRotateAction && (in.ValidFor < time.Hour || in.ValidFor > 365*24*time.Hour) {
|
||||
return fault.Invalid("credential validity must be between one hour and one year")
|
||||
}
|
||||
if in.Action == credentialRevokeAction && in.ValidFor != 0 {
|
||||
return fault.Invalid("revoke operation cannot set validity")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validCredentialReason(reason string) error {
|
||||
if strings.TrimSpace(reason) == "" || !utf8.ValidString(reason) || len([]rune(reason)) > 1024 || strings.ContainsAny(reason, "\x00\r\n") {
|
||||
return fault.Invalid("credential reason is required and must not contain control newlines")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func nullableTime(v sql.NullTime) *time.Time {
|
||||
if !v.Valid {
|
||||
return nil
|
||||
}
|
||||
t := v.Time.UTC()
|
||||
return &t
|
||||
}
|
||||
|
||||
func timePtr(v time.Time) *time.Time {
|
||||
t := v.UTC()
|
||||
return &t
|
||||
}
|
||||
Reference in New Issue
Block a user