diff --git a/api/database/v1alpha1/common_types.go b/api/database/v1alpha1/common_types.go index ecbf3f4..7a15593 100644 --- a/api/database/v1alpha1/common_types.go +++ b/api/database/v1alpha1/common_types.go @@ -54,8 +54,8 @@ const ( ReclaimDelete ReclaimPolicy = "Delete" ) -// CredentialReference 定位已有 OpenBao KV v2 凭据,不包含任何秘密值。 -// 只由资源管理员在导入时填写;controller 必须检查部署允许的 mount/path 范围。 +// CredentialReference 定位 OpenBao KV v2 凭据,不包含任何秘密值。 +// 管理员在导入声明中指定,controller 在 status 中固定位置;两者均须检查部署允许的范围。 type CredentialReference struct { // +kubebuilder:validation:MinLength=1 // +kubebuilder:validation:MaxLength=253 diff --git a/api/database/v1alpha1/credential_status_test.go b/api/database/v1alpha1/credential_status_test.go new file mode 100644 index 0000000..0482a9a --- /dev/null +++ b/api/database/v1alpha1/credential_status_test.go @@ -0,0 +1,87 @@ +package v1alpha1_test + +import ( + "testing" + + databasev1alpha1 "git.ddupan.top/panxiao81/ayatori/api/database/v1alpha1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +func testCredentialStatus(t *testing.T, client ctrlclient.Client) { + database := validDatabase("credential-status") + if err := client.Create(t.Context(), database); err != nil { + t.Fatal(err) + } + withoutLocation := database.DeepCopy() + withoutLocation.Status.CredentialVersion = 1 + if err := client.Status().Update(t.Context(), withoutLocation); !apierrors.IsInvalid(err) { + t.Fatalf("没有位置不能确认版本: %v", err) + } + database.Status.CredentialRef = &databasev1alpha1.CredentialReference{ + Mount: "application-secrets", Path: "applications/" + string(database.UID), + } + if err := client.Status().Update(t.Context(), database); err != nil { + t.Fatal(err) + } + testCredentialStatusRemoval(t, client, database) + changedTarget := database.DeepCopy() + changedTarget.Spec.Database = "another_database" + if err := client.Update(t.Context(), changedTarget); !apierrors.IsInvalid(err) { + t.Fatalf("凭据位置固定后不得更换实际目标: %v", err) + } + stale := database.DeepCopy() + database.Status.CredentialVersion = 1 + if err := client.Status().Update(t.Context(), database); err != nil { + t.Fatal(err) + } + stale.Status.Phase = "Binding" + if err := client.Status().Update(t.Context(), stale); !apierrors.IsConflict(err) { + t.Fatalf("旧 resourceVersion 不得覆盖确认结果: %v", err) + } + testCredentialStatusRemoval(t, client, database) + for _, version := range []int64{0, -1, 2} { + changed := database.DeepCopy() + changed.Status.CredentialVersion = version + if err := client.Status().Update(t.Context(), changed); !apierrors.IsInvalid(err) { + t.Fatalf("不可移除或更改确认版本 %d: %v", version, err) + } + } + database.Status.Conditions = []metav1.Condition{{ + Type: "CredentialsReady", Status: metav1.ConditionFalse, + Reason: "DependencyUnavailable", Message: "凭据暂时无法读取", + ObservedGeneration: database.Generation, LastTransitionTime: metav1.Now(), + }} + if err := client.Status().Update(t.Context(), database); err != nil { + t.Fatalf("当前不可用不应阻止保留确认记录: %v", err) + } + reloaded := &databasev1alpha1.PostgreSQLDatabase{} + if err := client.Get(t.Context(), ctrlclient.ObjectKeyFromObject(database), reloaded); err != nil { + t.Fatal(err) + } + if reloaded.Status.CredentialVersion != 1 || reloaded.Status.CredentialRef == nil { + t.Fatal("重读丢失凭据确认记录") + } +} + +func testCredentialStatusRemoval(t *testing.T, client ctrlclient.Client, database *databasev1alpha1.PostgreSQLDatabase) { + t.Helper() + for _, change := range []struct { + name string + edit func(*databasev1alpha1.PostgreSQLDatabase) + }{ + {"移除整个 status", func(d *databasev1alpha1.PostgreSQLDatabase) { d.Status = databasev1alpha1.PostgreSQLDatabaseStatus{} }}, + {"移除位置", func(d *databasev1alpha1.PostgreSQLDatabase) { d.Status.CredentialRef = nil }}, + {"修改 mount", func(d *databasev1alpha1.PostgreSQLDatabase) { d.Status.CredentialRef.Mount = "other" }}, + {"修改 path", func(d *databasev1alpha1.PostgreSQLDatabase) { d.Status.CredentialRef.Path = "applications/other" }}, + } { + t.Run(change.name, func(t *testing.T) { + changed := database.DeepCopy() + change.edit(changed) + if err := client.Status().Update(t.Context(), changed); !apierrors.IsInvalid(err) { + t.Fatalf("不应接受已固定凭据位置的更改: %v", err) + } + }) + } +} diff --git a/api/database/v1alpha1/postgresqldatabase_types.go b/api/database/v1alpha1/postgresqldatabase_types.go index 9166b40..faef6b5 100644 --- a/api/database/v1alpha1/postgresqldatabase_types.go +++ b/api/database/v1alpha1/postgresqldatabase_types.go @@ -30,6 +30,14 @@ type PostgreSQLDatabaseStatus struct { // InstanceUID 记录观察时的实例身份,不把同名新实例视为原目标。 // +optional InstanceUID types.UID `json:"instanceUID,omitempty"` + // CredentialRef 在首次外部写入前固定凭据位置;部署配置变化不迁移此位置。 + // +optional + CredentialRef *CredentialReference `json:"credentialRef,omitempty"` + // CredentialVersion 只在创建并回读成功后记录,不表示凭据当前仍可用。 + // 省略表示尚未确认;已有凭据不能仅凭读取成功补记确认。 + // +kubebuilder:validation:Minimum=1 + // +optional + CredentialVersion int64 `json:"credentialVersion,omitempty"` // Phase 暂不冻结供应子阶段枚举;它不是操作授权或绑定的替代记录。 // +optional Phase string `json:"phase,omitempty"` @@ -42,7 +50,10 @@ type PostgreSQLDatabaseStatus struct { // +kubebuilder:object:root=true // +kubebuilder:subresource:status // +kubebuilder:resource:scope=Cluster -// +kubebuilder:validation:XValidation:rule="!(has(oldSelf.spec.tenantRef) || (has(oldSelf.status) && has(oldSelf.status.instanceUID))) || (self.spec.instanceRef == oldSelf.spec.instanceRef && self.spec.database == oldSelf.spec.database && self.spec.loginRole == oldSelf.spec.loginRole && self.spec.source == oldSelf.spec.source && has(self.spec.credentialRef) == has(oldSelf.spec.credentialRef) && (!has(oldSelf.spec.credentialRef) || self.spec.credentialRef == oldSelf.spec.credentialRef))",message="managed database target cannot change after observation or binding starts" +// +kubebuilder:validation:XValidation:rule="!has(self.status) || !has(self.status.credentialVersion) || has(self.status.credentialRef)",message="credentialVersion requires credentialRef" +// +kubebuilder:validation:XValidation:rule="!(has(oldSelf.status) && has(oldSelf.status.credentialRef)) || (has(self.status) && has(self.status.credentialRef) && self.status.credentialRef == oldSelf.status.credentialRef)",message="recorded credentialRef cannot change or be removed" +// +kubebuilder:validation:XValidation:rule="!(has(oldSelf.status) && has(oldSelf.status.credentialVersion)) || (has(self.status) && has(self.status.credentialVersion) && self.status.credentialVersion == oldSelf.status.credentialVersion)",message="confirmed credentialVersion cannot change or be removed" +// +kubebuilder:validation:XValidation:rule="!(has(oldSelf.spec.tenantRef) || (has(oldSelf.status) && (has(oldSelf.status.instanceUID) || has(oldSelf.status.credentialRef)))) || (self.spec.instanceRef == oldSelf.spec.instanceRef && self.spec.database == oldSelf.spec.database && self.spec.loginRole == oldSelf.spec.loginRole && self.spec.source == oldSelf.spec.source && has(self.spec.credentialRef) == has(oldSelf.spec.credentialRef) && (!has(oldSelf.spec.credentialRef) || self.spec.credentialRef == oldSelf.spec.credentialRef))",message="managed database target cannot change after observation or binding starts" // +kubebuilder:printcolumn:name="Instance",type=string,JSONPath=`.spec.instanceRef.name` // +kubebuilder:printcolumn:name="Database",type=string,JSONPath=`.spec.database` // +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=='Ready')].status` diff --git a/api/database/v1alpha1/validation_test.go b/api/database/v1alpha1/validation_test.go index e85aac8..388f592 100644 --- a/api/database/v1alpha1/validation_test.go +++ b/api/database/v1alpha1/validation_test.go @@ -61,6 +61,7 @@ func TestDatabaseAPI(t *testing.T) { t.Run("作用域和默认值", func(t *testing.T) { testDefaults(t, client) }) t.Run("拒绝非法声明", func(t *testing.T) { testInvalidDeclarations(t, client) }) t.Run("status隔离和绑定并发", func(t *testing.T) { testBindingWrites(t, client) }) + t.Run("凭据位置与确认版本", func(t *testing.T) { testCredentialStatus(t, client) }) t.Run("仓库示例", func(t *testing.T) { testSamples(t, client, scheme) }) } diff --git a/api/database/v1alpha1/zz_generated.deepcopy.go b/api/database/v1alpha1/zz_generated.deepcopy.go index 32e3e34..369a73b 100644 --- a/api/database/v1alpha1/zz_generated.deepcopy.go +++ b/api/database/v1alpha1/zz_generated.deepcopy.go @@ -188,6 +188,11 @@ func (in *PostgreSQLDatabaseSpec) DeepCopy() *PostgreSQLDatabaseSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PostgreSQLDatabaseStatus) DeepCopyInto(out *PostgreSQLDatabaseStatus) { *out = *in + if in.CredentialRef != nil { + in, out := &in.CredentialRef, &out.CredentialRef + *out = new(CredentialReference) + **out = **in + } if in.Conditions != nil { in, out := &in.Conditions, &out.Conditions *out = make([]v1.Condition, len(*in)) diff --git a/config/crd/bases/database.ayatori.ddupan.top_postgresqldatabases.yaml b/config/crd/bases/database.ayatori.ddupan.top_postgresqldatabases.yaml index 9ca893a..8ff07a1 100644 --- a/config/crd/bases/database.ayatori.ddupan.top_postgresqldatabases.yaml +++ b/config/crd/bases/database.ayatori.ddupan.top_postgresqldatabases.yaml @@ -50,8 +50,8 @@ spec: properties: credentialRef: description: |- - CredentialReference 定位已有 OpenBao KV v2 凭据,不包含任何秘密值。 - 只由资源管理员在导入时填写;controller 必须检查部署允许的 mount/path 范围。 + CredentialReference 定位 OpenBao KV v2 凭据,不包含任何秘密值。 + 管理员在导入声明中指定,controller 在 status 中固定位置;两者均须检查部署允许的范围。 properties: mount: maxLength: 253 @@ -198,6 +198,29 @@ spec: x-kubernetes-list-map-keys: - type x-kubernetes-list-type: map + credentialRef: + description: CredentialRef 在首次外部写入前固定凭据位置;部署配置变化不迁移此位置。 + properties: + mount: + maxLength: 253 + minLength: 1 + type: string + path: + description: Path 是 mount 内的逻辑路径,不含 KV v2 的 data/ API 前缀。 + maxLength: 1024 + minLength: 1 + type: string + required: + - mount + - path + type: object + credentialVersion: + description: |- + CredentialVersion 只在创建并回读成功后记录,不表示凭据当前仍可用。 + 省略表示尚未确认;已有凭据不能仅凭读取成功补记确认。 + format: int64 + minimum: 1 + type: integer instanceUID: description: InstanceUID 记录观察时的实例身份,不把同名新实例视为原目标。 type: string @@ -212,13 +235,22 @@ spec: - spec type: object x-kubernetes-validations: + - message: credentialVersion requires credentialRef + rule: '!has(self.status) || !has(self.status.credentialVersion) || has(self.status.credentialRef)' + - message: recorded credentialRef cannot change or be removed + rule: '!(has(oldSelf.status) && has(oldSelf.status.credentialRef)) || (has(self.status) + && has(self.status.credentialRef) && self.status.credentialRef == oldSelf.status.credentialRef)' + - message: confirmed credentialVersion cannot change or be removed + rule: '!(has(oldSelf.status) && has(oldSelf.status.credentialVersion)) || + (has(self.status) && has(self.status.credentialVersion) && self.status.credentialVersion + == oldSelf.status.credentialVersion)' - message: managed database target cannot change after observation or binding starts - rule: '!(has(oldSelf.spec.tenantRef) || (has(oldSelf.status) && has(oldSelf.status.instanceUID))) - || (self.spec.instanceRef == oldSelf.spec.instanceRef && self.spec.database - == oldSelf.spec.database && self.spec.loginRole == oldSelf.spec.loginRole - && self.spec.source == oldSelf.spec.source && has(self.spec.credentialRef) - == has(oldSelf.spec.credentialRef) && (!has(oldSelf.spec.credentialRef) + rule: '!(has(oldSelf.spec.tenantRef) || (has(oldSelf.status) && (has(oldSelf.status.instanceUID) + || has(oldSelf.status.credentialRef)))) || (self.spec.instanceRef == oldSelf.spec.instanceRef + && self.spec.database == oldSelf.spec.database && self.spec.loginRole + == oldSelf.spec.loginRole && self.spec.source == oldSelf.spec.source && + has(self.spec.credentialRef) == has(oldSelf.spec.credentialRef) && (!has(oldSelf.spec.credentialRef) || self.spec.credentialRef == oldSelf.spec.credentialRef))' served: true storage: true diff --git a/docs/database/README.md b/docs/database/README.md index 899cc7e..9b10354 100644 --- a/docs/database/README.md +++ b/docs/database/README.md @@ -169,7 +169,12 @@ Instance 删除首先释放本地连接并撤销 Ready。任何引用它的 Data 拒绝管理路径,以及成功写入后丢失响应;HTTP 故障测试补充不重试和错误脱敏。 认证会话已按下面的显式参数接入 manager;凭据存储尚未接入供应用例。 -Database 状态中的稳定位置和已确认步骤、供应 service/controller、PostgreSQL 创建以及 ESO 交付仍未完成。 +Database 已有 `status.credentialRef` 和 `status.credentialVersion` 的字段与 CEL 校验: +固定位置、只在创建并回读成功后确认版本,二者写入后不可清空或修改。 +`ReadConfirmed` 按已确认版本检查最新 KV 值,删除或版本漂移报 Conflict,不读取旧版本掩盖变化。 +真实 API server 验证 status 清除拒绝、版本冲突和 Conditions 更新;真实 Bao 验证重建 adapter +后读取、缺少确认记录拒绝、版本变化与软删除。字段持久化的供应 service/controller、 +PostgreSQL 创建以及 ESO 交付仍未完成,本切片不是已接入的凭据准备流程。 测试 token 只用于临时 fixture,不是生产静态 token 配置接口。现有绑定不会触发外部写入。 ## OpenBao Kubernetes 认证会话 diff --git a/docs/database/api-reference.md b/docs/database/api-reference.md index be022e7..25755e9 100644 --- a/docs/database/api-reference.md +++ b/docs/database/api-reference.md @@ -4,7 +4,7 @@ | --- | --- | | 状态 | API schema 与绑定 controller 已实现;供应、交付与删除清理未接入 | | API group/version | `database.ayatori.ddupan.top/v1alpha1` | -| 最后更新 | 2026-09-25 | +| 最后更新 | 2026-09-27 | 以 [系统规格](specification.md) 与 [ADR-0009](../decisions/0009-database-resource-and-claim.md) 为准。类型与生成的 CRD 已纳入源码, @@ -24,6 +24,8 @@ Go 类型位于 `api/database/v1alpha1`,CRD 随 `config/crd` 发布;manager | Database | `spec.reclaimPolicy` | Retain 默认或 Delete | | Database | `spec.tenantRef.namespace/name/uid` | controller 写入的完整绑定身份,不是允许名单 | | Database | `status.instanceUID` | 观察时的 Instance 身份 | +| Database | `status.credentialRef.mount/path` | 首次写入前固定的 KV v2 位置,不随部署配置迁移 | +| Database | `status.credentialVersion` | 创建并回读成功后确认的正整数版本;省略表示未确认 | | Tenant | `spec.provision.instanceRef.name` | 动态申请来源,与 `spec.databaseRef` 互斥且必须二选一 | | Tenant | `spec.provision.database/loginRole` | 可省略,语义默认值由 controller 解析,不由 CRD 推导 | | Tenant | `spec.databaseRef.name` | 显式申请已有 Database,不额外指定 Instance | @@ -36,6 +38,12 @@ Instance phase 沿用已批准枚举;Database/Tenant phase 暂不冻结供应 `credentialRef.path` 是 mount 内逻辑路径,不包含 KV v2 的 `data/` 前缀。 其部署允许范围、实际凭据读取和 URL 安全构造仍由后续 adapter/controller 验证。 +凭据位置与确认版本一旦写入便不可更改或移除;确认版本必须有对应位置。Conditions 描述 +当前可用性,不替代确认记录,也不能因读取暂时失败而清空记录。上述 schema 已有真实 API +server 校验;负责持久化它们的供应用例尚未接入,现有绑定 controller 不会填写这些字段。 +未确认的已有值必须报 Conflict,不能用读取成功补记版本;已确认版本的恢复读取检查最新 +KV 版本,删除或版本漂移均需人工处理,不回退旧版本或生成替代密码。第一版不提供轮换入口。 + 示例:[Instance](../../config/samples/database_v1alpha1_postgresqlinstance.yaml)、 [导入 Database](../../config/samples/database_v1alpha1_postgresqldatabase.yaml)、 [动态/已有资源申请](../../config/samples/database_v1alpha1_postgresqltenant.yaml)。 @@ -47,7 +55,7 @@ API 接受两个 Tenant 引用同一 Database 不表示允许双重绑定;排 已有资源必须有当前版本 Ready 观察、匹配的 Instance UID,并处于未绑定的 Available 状态。 同一 Tenant 的资源侧记录已写入时,允许回读后补齐申请侧,不重新争抢资源。 -Tenant 进入 `status.phase=Binding` 后由 CEL 固定申请目标;Database 有实例身份观察或 +Tenant 进入 `status.phase=Binding` 后由 CEL 固定申请目标;Database 有实例身份观察、凭据位置或 绑定后固定实际 database、loginRole、来源和凭据引用,回收策略仍可修改。 读取绑定判断使用 APIReader,写入依靠 resourceVersion;watch/cache 负责触发协调。 绑定顺序由 application service 协调,纯资格规则在领域层;Kubernetes adapter 负责快照 diff --git a/docs/database/specification.md b/docs/database/specification.md index 5d8c3fc..6b1988d 100644 --- a/docs/database/specification.md +++ b/docs/database/specification.md @@ -192,6 +192,12 @@ mount/base path 属部署配置,Tenant 不得自选任意路径;原按 Tenan 位置,不要求搬迁已有凭据。Released 不自动改密,管理员处理旧访问后才重新开放资源。 不得因换 Tenant、改部署参数或重新绑定就隐式搬迁凭据或改密。 +2026-09-27 确认最小凭据记录:Database `status.credentialRef` 在首次外部写入前固定 +mount/path;`status.credentialVersion` 仅在成功创建并回读后保存 KV 版本。位置和确认版本 +不得自动更改或清空,Conditions 只描述当前可用性。已有值但无确认版本时报告 Conflict, +不能靠读取成功认领;已确认凭据消失或最新版本不一致同样停止,等待人工核实。 +部署参数变化不得搬迁旧位置;确认记录写入的 resourceVersion 冲突不能通过盲目重试覆盖。 + TLS、OpenBao Kubernetes auth、controller/ESO 身份隔离、Secret 读取范围和防泄漏要求 见 [安全模型](security.md)。这些安全约束继续适用。 diff --git a/internal/database/adapter/openbao/confirmed_credentials_integration_test.go b/internal/database/adapter/openbao/confirmed_credentials_integration_test.go new file mode 100644 index 0000000..ee64325 --- /dev/null +++ b/internal/database/adapter/openbao/confirmed_credentials_integration_test.go @@ -0,0 +1,47 @@ +//go:build integration + +package openbao_test + +import ( + "maps" + "testing" + + bao "github.com/openbao/openbao/api/v2" + + "git.ddupan.top/panxiao81/ayatori/internal/database/adapter/openbao" +) + +func TestConfirmedCredentialRecoveryWithRealOpenBao(t *testing.T) { + root := baoFixture(t) + store := fixtureStore(t, root) + credential := fixtureCredential(t) + if err := store.Create(t.Context(), credentialPath, credential); err != nil { + t.Fatal(err) + } + // 新 adapter 模拟进程重启:只有已持久化的版本才能恢复读取。 + restarted := fixtureStore(t, root) + if _, err := restarted.ReadConfirmed(t.Context(), credentialPath, 0); err != openbao.ErrConflict { + t.Fatal("已有值不能替代缺失的确认记录") + } + observed, err := restarted.ReadConfirmed(t.Context(), credentialPath, 1) + if err != nil || !maps.Equal(observed.SecretData(), credential.SecretData()) { + t.Fatal("确认版本的凭据不能恢复读取") + } + // 即使内容相同,新增版本也不属于已确认写入;历史版本仍在不能掩盖改写。 + if _, err := root.KVv2("secret").Put(t.Context(), credentialPath, credential.SecretData(), bao.WithCheckAndSet(1)); err != nil { + t.Fatal("无法准备测试中的版本变化") + } + if _, err := restarted.ReadConfirmed(t.Context(), credentialPath, 1); err != openbao.ErrConflict { + t.Fatal("最新版本漂移必须报冲突") + } + if err := root.KVv2("secret").Delete(t.Context(), credentialPath); err != nil { + t.Fatal("无法准备测试中的软删除") + } + if _, err := restarted.ReadConfirmed(t.Context(), credentialPath, 1); err != openbao.ErrConflict { + t.Fatal("已确认凭据被删除必须报冲突") + } + metadata, err := root.KVv2("secret").GetMetadata(t.Context(), credentialPath) + if err != nil || metadata.CurrentVersion != 2 { + t.Fatal("恢复检查不得生成或覆盖凭据") + } +} diff --git a/internal/database/adapter/openbao/confirmed_credentials_test.go b/internal/database/adapter/openbao/confirmed_credentials_test.go new file mode 100644 index 0000000..4b9b477 --- /dev/null +++ b/internal/database/adapter/openbao/confirmed_credentials_test.go @@ -0,0 +1,69 @@ +package openbao_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "git.ddupan.top/panxiao81/ayatori/internal/database/adapter/openbao" +) + +func credentialVersionMetadata(version int) map[string]any { + return map[string]any{kvVersionKey: version} +} + +func TestConfirmedCredentialRead(t *testing.T) { + for _, test := range []struct { + name string + version int64 + status int + meta map[string]any + invalid bool + want error + }{ + {name: "已确认", version: 1, status: 200, meta: credentialVersionMetadata(1)}, + {name: "未确认禁止读取", version: 0, want: openbao.ErrConflict}, + {name: "版本已变化", version: 1, status: 200, meta: credentialVersionMetadata(2), want: openbao.ErrConflict}, + {name: "版本为零", version: 1, status: 200, meta: credentialVersionMetadata(0), want: openbao.ErrConflict}, + {name: "响应无法解析", version: 1, status: 200, want: openbao.ErrUnavailable}, + {name: "内容损坏", version: 1, status: 200, meta: credentialVersionMetadata(1), invalid: true, want: openbao.ErrConflict}, + {name: "凭据丢失", version: 1, status: 404, want: openbao.ErrConflict}, + {name: "读取被拒绝", version: 1, status: 403, want: openbao.ErrUnavailable}, + {name: "后端不可用", version: 1, status: 503, want: openbao.ErrUnavailable}, + } { + t.Run(test.name, func(t *testing.T) { + credential := fixtureCredential(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if test.version == 0 { + t.Error("没有持久化确认记录不能读取已有值") + } + if r.Method != http.MethodGet || r.URL.RawQuery != "" { + t.Error("只允许读取最新值,不能写入或回退历史版本") + } + w.WriteHeader(test.status) + if test.status != http.StatusOK { + return + } + data := credential.SecretData() + if test.invalid { + delete(data, "password") + } + if err := json.NewEncoder(w).Encode(map[string]any{ + kvDataKey: map[string]any{kvDataKey: data, "metadata": test.meta}, + }); err != nil { + t.Error("测试响应编码失败") + } + })) + defer server.Close() + store := fixtureStore(t, fixtureClient(t, server.URL)) + observed, err := store.ReadConfirmed(t.Context(), credentialPath, test.version) + if err != test.want { + t.Fatalf("期望 %v,得到 %v", test.want, err) + } + if err != nil && observed.Validate() == nil { + t.Fatal("失败时不能返回可用凭据") + } + }) + } +} diff --git a/internal/database/adapter/openbao/credentials.go b/internal/database/adapter/openbao/credentials.go index ecfddd8..461c767 100644 --- a/internal/database/adapter/openbao/credentials.go +++ b/internal/database/adapter/openbao/credentials.go @@ -80,20 +80,51 @@ func (c *Credentials) accepts(path string) bool { // Read 只读取调用方已确认关联的路径;成功读取不构成对既有凭据的自动认领。 func (c *Credentials) Read(ctx context.Context, path string) (application.ApplicationCredential, error) { + secret, err := c.read(ctx, path) + if err != nil { + return application.ApplicationCredential{}, err + } + return application.ParseApplicationCredential(secret.Data) +} + +// ReadConfirmed 读取最新值并核对已持久化的确认版本,不回退读取历史版本。 +// 确认后的删除或改写需要人工处理,不能因此重新生成密码。 +func (c *Credentials) ReadConfirmed(ctx context.Context, path string, version int64) (application.ApplicationCredential, error) { + if version < 1 { + return application.ApplicationCredential{}, ErrConflict + } + secret, err := c.read(ctx, path) + if errors.Is(err, ErrNotFound) { + return application.ApplicationCredential{}, ErrConflict + } + if err != nil { + return application.ApplicationCredential{}, err + } + if secret.VersionMetadata == nil || int64(secret.VersionMetadata.Version) != version { + return application.ApplicationCredential{}, ErrConflict + } + credential, err := application.ParseApplicationCredential(secret.Data) + if err != nil { + return application.ApplicationCredential{}, ErrConflict + } + return credential, nil +} + +func (c *Credentials) read(ctx context.Context, path string) (*bao.KVSecret, error) { if !c.accepts(path) { - return application.ApplicationCredential{}, ErrInvalidLocation + return nil, ErrInvalidLocation } secret, err := c.kv.Get(ctx, path) if errors.Is(err, bao.ErrSecretNotFound) { - return application.ApplicationCredential{}, ErrNotFound + return nil, ErrNotFound } if err != nil { - return application.ApplicationCredential{}, ErrUnavailable + return nil, ErrUnavailable } if secret == nil || secret.Data == nil { - return application.ApplicationCredential{}, ErrNotFound + return nil, ErrNotFound } - return application.ParseApplicationCredential(secret.Data) + return secret, nil } // Create 只创建从未存在过的路径,并验证回读七键与提交值完全一致。 diff --git a/internal/database/adapter/openbao/credentials_test.go b/internal/database/adapter/openbao/credentials_test.go index b164173..c065716 100644 --- a/internal/database/adapter/openbao/credentials_test.go +++ b/internal/database/adapter/openbao/credentials_test.go @@ -36,6 +36,7 @@ const ( fixturePassword = "AYATORI-TEST-ONLY-application-password" fixtureToken = "AYATORI-TEST-ONLY-bao-token" kvDataKey = "data" + kvVersionKey = "version" ) func fixtureCredential(t *testing.T) application.ApplicationCredential { @@ -66,7 +67,7 @@ func TestCredentialReadbackMustConfirmTheWrite(t *testing.T) { if json.NewDecoder(r.Body).Decode(&request) != nil || request.Options.CAS == nil || *request.Options.CAS != 0 { t.Error("create request must explicitly require CAS=0") } - if err := json.NewEncoder(w).Encode(map[string]any{kvDataKey: map[string]any{"version": 1}}); err != nil { + if err := json.NewEncoder(w).Encode(map[string]any{kvDataKey: map[string]any{kvVersionKey: 1}}); err != nil { t.Error("cannot encode fixture write response") } return @@ -85,7 +86,7 @@ func TestCredentialReadbackMustConfirmTheWrite(t *testing.T) { } response := map[string]any{kvDataKey: data} if scenario != "missing metadata" { - response["metadata"] = map[string]any{"version": version} + response["metadata"] = map[string]any{kvVersionKey: version} } if err := json.NewEncoder(w).Encode(map[string]any{kvDataKey: response}); err != nil { t.Error("cannot encode fixture read response")