feat: 固定 Database 凭据位置与确认版本
This commit is contained in:
@@ -54,8 +54,8 @@ const (
|
|||||||
ReclaimDelete ReclaimPolicy = "Delete"
|
ReclaimDelete ReclaimPolicy = "Delete"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CredentialReference 定位已有 OpenBao KV v2 凭据,不包含任何秘密值。
|
// CredentialReference 定位 OpenBao KV v2 凭据,不包含任何秘密值。
|
||||||
// 只由资源管理员在导入时填写;controller 必须检查部署允许的 mount/path 范围。
|
// 管理员在导入声明中指定,controller 在 status 中固定位置;两者均须检查部署允许的范围。
|
||||||
type CredentialReference struct {
|
type CredentialReference struct {
|
||||||
// +kubebuilder:validation:MinLength=1
|
// +kubebuilder:validation:MinLength=1
|
||||||
// +kubebuilder:validation:MaxLength=253
|
// +kubebuilder:validation:MaxLength=253
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,14 @@ type PostgreSQLDatabaseStatus struct {
|
|||||||
// InstanceUID 记录观察时的实例身份,不把同名新实例视为原目标。
|
// InstanceUID 记录观察时的实例身份,不把同名新实例视为原目标。
|
||||||
// +optional
|
// +optional
|
||||||
InstanceUID types.UID `json:"instanceUID,omitempty"`
|
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 暂不冻结供应子阶段枚举;它不是操作授权或绑定的替代记录。
|
// Phase 暂不冻结供应子阶段枚举;它不是操作授权或绑定的替代记录。
|
||||||
// +optional
|
// +optional
|
||||||
Phase string `json:"phase,omitempty"`
|
Phase string `json:"phase,omitempty"`
|
||||||
@@ -42,7 +50,10 @@ type PostgreSQLDatabaseStatus struct {
|
|||||||
// +kubebuilder:object:root=true
|
// +kubebuilder:object:root=true
|
||||||
// +kubebuilder:subresource:status
|
// +kubebuilder:subresource:status
|
||||||
// +kubebuilder:resource:scope=Cluster
|
// +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="Instance",type=string,JSONPath=`.spec.instanceRef.name`
|
||||||
// +kubebuilder:printcolumn:name="Database",type=string,JSONPath=`.spec.database`
|
// +kubebuilder:printcolumn:name="Database",type=string,JSONPath=`.spec.database`
|
||||||
// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=='Ready')].status`
|
// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=='Ready')].status`
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ func TestDatabaseAPI(t *testing.T) {
|
|||||||
t.Run("作用域和默认值", func(t *testing.T) { testDefaults(t, client) })
|
t.Run("作用域和默认值", func(t *testing.T) { testDefaults(t, client) })
|
||||||
t.Run("拒绝非法声明", func(t *testing.T) { testInvalidDeclarations(t, client) })
|
t.Run("拒绝非法声明", func(t *testing.T) { testInvalidDeclarations(t, client) })
|
||||||
t.Run("status隔离和绑定并发", func(t *testing.T) { testBindingWrites(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) })
|
t.Run("仓库示例", func(t *testing.T) { testSamples(t, client, scheme) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||||
func (in *PostgreSQLDatabaseStatus) DeepCopyInto(out *PostgreSQLDatabaseStatus) {
|
func (in *PostgreSQLDatabaseStatus) DeepCopyInto(out *PostgreSQLDatabaseStatus) {
|
||||||
*out = *in
|
*out = *in
|
||||||
|
if in.CredentialRef != nil {
|
||||||
|
in, out := &in.CredentialRef, &out.CredentialRef
|
||||||
|
*out = new(CredentialReference)
|
||||||
|
**out = **in
|
||||||
|
}
|
||||||
if in.Conditions != nil {
|
if in.Conditions != nil {
|
||||||
in, out := &in.Conditions, &out.Conditions
|
in, out := &in.Conditions, &out.Conditions
|
||||||
*out = make([]v1.Condition, len(*in))
|
*out = make([]v1.Condition, len(*in))
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ spec:
|
|||||||
properties:
|
properties:
|
||||||
credentialRef:
|
credentialRef:
|
||||||
description: |-
|
description: |-
|
||||||
CredentialReference 定位已有 OpenBao KV v2 凭据,不包含任何秘密值。
|
CredentialReference 定位 OpenBao KV v2 凭据,不包含任何秘密值。
|
||||||
只由资源管理员在导入时填写;controller 必须检查部署允许的 mount/path 范围。
|
管理员在导入声明中指定,controller 在 status 中固定位置;两者均须检查部署允许的范围。
|
||||||
properties:
|
properties:
|
||||||
mount:
|
mount:
|
||||||
maxLength: 253
|
maxLength: 253
|
||||||
@@ -198,6 +198,29 @@ spec:
|
|||||||
x-kubernetes-list-map-keys:
|
x-kubernetes-list-map-keys:
|
||||||
- type
|
- type
|
||||||
x-kubernetes-list-type: map
|
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:
|
instanceUID:
|
||||||
description: InstanceUID 记录观察时的实例身份,不把同名新实例视为原目标。
|
description: InstanceUID 记录观察时的实例身份,不把同名新实例视为原目标。
|
||||||
type: string
|
type: string
|
||||||
@@ -212,13 +235,22 @@ spec:
|
|||||||
- spec
|
- spec
|
||||||
type: object
|
type: object
|
||||||
x-kubernetes-validations:
|
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
|
- message: managed database target cannot change after observation or binding
|
||||||
starts
|
starts
|
||||||
rule: '!(has(oldSelf.spec.tenantRef) || (has(oldSelf.status) && has(oldSelf.status.instanceUID)))
|
rule: '!(has(oldSelf.spec.tenantRef) || (has(oldSelf.status) && (has(oldSelf.status.instanceUID)
|
||||||
|| (self.spec.instanceRef == oldSelf.spec.instanceRef && self.spec.database
|
|| has(oldSelf.status.credentialRef)))) || (self.spec.instanceRef == oldSelf.spec.instanceRef
|
||||||
== oldSelf.spec.database && self.spec.loginRole == oldSelf.spec.loginRole
|
&& self.spec.database == oldSelf.spec.database && self.spec.loginRole
|
||||||
&& self.spec.source == oldSelf.spec.source && has(self.spec.credentialRef)
|
== oldSelf.spec.loginRole && self.spec.source == oldSelf.spec.source &&
|
||||||
== has(oldSelf.spec.credentialRef) && (!has(oldSelf.spec.credentialRef)
|
has(self.spec.credentialRef) == has(oldSelf.spec.credentialRef) && (!has(oldSelf.spec.credentialRef)
|
||||||
|| self.spec.credentialRef == oldSelf.spec.credentialRef))'
|
|| self.spec.credentialRef == oldSelf.spec.credentialRef))'
|
||||||
served: true
|
served: true
|
||||||
storage: true
|
storage: true
|
||||||
|
|||||||
@@ -169,7 +169,12 @@ Instance 删除首先释放本地连接并撤销 Ready。任何引用它的 Data
|
|||||||
拒绝管理路径,以及成功写入后丢失响应;HTTP 故障测试补充不重试和错误脱敏。
|
拒绝管理路径,以及成功写入后丢失响应;HTTP 故障测试补充不重试和错误脱敏。
|
||||||
|
|
||||||
认证会话已按下面的显式参数接入 manager;凭据存储尚未接入供应用例。
|
认证会话已按下面的显式参数接入 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 配置接口。现有绑定不会触发外部写入。
|
测试 token 只用于临时 fixture,不是生产静态 token 配置接口。现有绑定不会触发外部写入。
|
||||||
|
|
||||||
## OpenBao Kubernetes 认证会话
|
## OpenBao Kubernetes 认证会话
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 状态 | API schema 与绑定 controller 已实现;供应、交付与删除清理未接入 |
|
| 状态 | API schema 与绑定 controller 已实现;供应、交付与删除清理未接入 |
|
||||||
| API group/version | `database.ayatori.ddupan.top/v1alpha1` |
|
| API group/version | `database.ayatori.ddupan.top/v1alpha1` |
|
||||||
| 最后更新 | 2026-09-25 |
|
| 最后更新 | 2026-09-27 |
|
||||||
|
|
||||||
以 [系统规格](specification.md) 与
|
以 [系统规格](specification.md) 与
|
||||||
[ADR-0009](../decisions/0009-database-resource-and-claim.md) 为准。类型与生成的 CRD 已纳入源码,
|
[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.reclaimPolicy` | Retain 默认或 Delete |
|
||||||
| Database | `spec.tenantRef.namespace/name/uid` | controller 写入的完整绑定身份,不是允许名单 |
|
| Database | `spec.tenantRef.namespace/name/uid` | controller 写入的完整绑定身份,不是允许名单 |
|
||||||
| Database | `status.instanceUID` | 观察时的 Instance 身份 |
|
| Database | `status.instanceUID` | 观察时的 Instance 身份 |
|
||||||
|
| Database | `status.credentialRef.mount/path` | 首次写入前固定的 KV v2 位置,不随部署配置迁移 |
|
||||||
|
| Database | `status.credentialVersion` | 创建并回读成功后确认的正整数版本;省略表示未确认 |
|
||||||
| Tenant | `spec.provision.instanceRef.name` | 动态申请来源,与 `spec.databaseRef` 互斥且必须二选一 |
|
| Tenant | `spec.provision.instanceRef.name` | 动态申请来源,与 `spec.databaseRef` 互斥且必须二选一 |
|
||||||
| Tenant | `spec.provision.database/loginRole` | 可省略,语义默认值由 controller 解析,不由 CRD 推导 |
|
| Tenant | `spec.provision.database/loginRole` | 可省略,语义默认值由 controller 解析,不由 CRD 推导 |
|
||||||
| Tenant | `spec.databaseRef.name` | 显式申请已有 Database,不额外指定 Instance |
|
| Tenant | `spec.databaseRef.name` | 显式申请已有 Database,不额外指定 Instance |
|
||||||
@@ -36,6 +38,12 @@ Instance phase 沿用已批准枚举;Database/Tenant phase 暂不冻结供应
|
|||||||
`credentialRef.path` 是 mount 内逻辑路径,不包含 KV v2 的 `data/` 前缀。
|
`credentialRef.path` 是 mount 内逻辑路径,不包含 KV v2 的 `data/` 前缀。
|
||||||
其部署允许范围、实际凭据读取和 URL 安全构造仍由后续 adapter/controller 验证。
|
其部署允许范围、实际凭据读取和 URL 安全构造仍由后续 adapter/controller 验证。
|
||||||
|
|
||||||
|
凭据位置与确认版本一旦写入便不可更改或移除;确认版本必须有对应位置。Conditions 描述
|
||||||
|
当前可用性,不替代确认记录,也不能因读取暂时失败而清空记录。上述 schema 已有真实 API
|
||||||
|
server 校验;负责持久化它们的供应用例尚未接入,现有绑定 controller 不会填写这些字段。
|
||||||
|
未确认的已有值必须报 Conflict,不能用读取成功补记版本;已确认版本的恢复读取检查最新
|
||||||
|
KV 版本,删除或版本漂移均需人工处理,不回退旧版本或生成替代密码。第一版不提供轮换入口。
|
||||||
|
|
||||||
示例:[Instance](../../config/samples/database_v1alpha1_postgresqlinstance.yaml)、
|
示例:[Instance](../../config/samples/database_v1alpha1_postgresqlinstance.yaml)、
|
||||||
[导入 Database](../../config/samples/database_v1alpha1_postgresqldatabase.yaml)、
|
[导入 Database](../../config/samples/database_v1alpha1_postgresqldatabase.yaml)、
|
||||||
[动态/已有资源申请](../../config/samples/database_v1alpha1_postgresqltenant.yaml)。
|
[动态/已有资源申请](../../config/samples/database_v1alpha1_postgresqltenant.yaml)。
|
||||||
@@ -47,7 +55,7 @@ API 接受两个 Tenant 引用同一 Database 不表示允许双重绑定;排
|
|||||||
已有资源必须有当前版本 Ready 观察、匹配的 Instance UID,并处于未绑定的 Available 状态。
|
已有资源必须有当前版本 Ready 观察、匹配的 Instance UID,并处于未绑定的 Available 状态。
|
||||||
同一 Tenant 的资源侧记录已写入时,允许回读后补齐申请侧,不重新争抢资源。
|
同一 Tenant 的资源侧记录已写入时,允许回读后补齐申请侧,不重新争抢资源。
|
||||||
|
|
||||||
Tenant 进入 `status.phase=Binding` 后由 CEL 固定申请目标;Database 有实例身份观察或
|
Tenant 进入 `status.phase=Binding` 后由 CEL 固定申请目标;Database 有实例身份观察、凭据位置或
|
||||||
绑定后固定实际 database、loginRole、来源和凭据引用,回收策略仍可修改。
|
绑定后固定实际 database、loginRole、来源和凭据引用,回收策略仍可修改。
|
||||||
读取绑定判断使用 APIReader,写入依靠 resourceVersion;watch/cache 负责触发协调。
|
读取绑定判断使用 APIReader,写入依靠 resourceVersion;watch/cache 负责触发协调。
|
||||||
绑定顺序由 application service 协调,纯资格规则在领域层;Kubernetes adapter 负责快照
|
绑定顺序由 application service 协调,纯资格规则在领域层;Kubernetes adapter 负责快照
|
||||||
|
|||||||
@@ -192,6 +192,12 @@ mount/base path 属部署配置,Tenant 不得自选任意路径;原按 Tenan
|
|||||||
位置,不要求搬迁已有凭据。Released 不自动改密,管理员处理旧访问后才重新开放资源。
|
位置,不要求搬迁已有凭据。Released 不自动改密,管理员处理旧访问后才重新开放资源。
|
||||||
不得因换 Tenant、改部署参数或重新绑定就隐式搬迁凭据或改密。
|
不得因换 Tenant、改部署参数或重新绑定就隐式搬迁凭据或改密。
|
||||||
|
|
||||||
|
2026-09-27 确认最小凭据记录:Database `status.credentialRef` 在首次外部写入前固定
|
||||||
|
mount/path;`status.credentialVersion` 仅在成功创建并回读后保存 KV 版本。位置和确认版本
|
||||||
|
不得自动更改或清空,Conditions 只描述当前可用性。已有值但无确认版本时报告 Conflict,
|
||||||
|
不能靠读取成功认领;已确认凭据消失或最新版本不一致同样停止,等待人工核实。
|
||||||
|
部署参数变化不得搬迁旧位置;确认记录写入的 resourceVersion 冲突不能通过盲目重试覆盖。
|
||||||
|
|
||||||
TLS、OpenBao Kubernetes auth、controller/ESO 身份隔离、Secret 读取范围和防泄漏要求
|
TLS、OpenBao Kubernetes auth、controller/ESO 身份隔离、Secret 读取范围和防泄漏要求
|
||||||
见 [安全模型](security.md)。这些安全约束继续适用。
|
见 [安全模型](security.md)。这些安全约束继续适用。
|
||||||
|
|
||||||
|
|||||||
@@ -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("恢复检查不得生成或覆盖凭据")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("失败时不能返回可用凭据")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -80,20 +80,51 @@ func (c *Credentials) accepts(path string) bool {
|
|||||||
|
|
||||||
// Read 只读取调用方已确认关联的路径;成功读取不构成对既有凭据的自动认领。
|
// Read 只读取调用方已确认关联的路径;成功读取不构成对既有凭据的自动认领。
|
||||||
func (c *Credentials) Read(ctx context.Context, path string) (application.ApplicationCredential, error) {
|
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) {
|
if !c.accepts(path) {
|
||||||
return application.ApplicationCredential{}, ErrInvalidLocation
|
return nil, ErrInvalidLocation
|
||||||
}
|
}
|
||||||
secret, err := c.kv.Get(ctx, path)
|
secret, err := c.kv.Get(ctx, path)
|
||||||
if errors.Is(err, bao.ErrSecretNotFound) {
|
if errors.Is(err, bao.ErrSecretNotFound) {
|
||||||
return application.ApplicationCredential{}, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return application.ApplicationCredential{}, ErrUnavailable
|
return nil, ErrUnavailable
|
||||||
}
|
}
|
||||||
if secret == nil || secret.Data == nil {
|
if secret == nil || secret.Data == nil {
|
||||||
return application.ApplicationCredential{}, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
return application.ParseApplicationCredential(secret.Data)
|
return secret, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create 只创建从未存在过的路径,并验证回读七键与提交值完全一致。
|
// Create 只创建从未存在过的路径,并验证回读七键与提交值完全一致。
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ const (
|
|||||||
fixturePassword = "AYATORI-TEST-ONLY-application-password"
|
fixturePassword = "AYATORI-TEST-ONLY-application-password"
|
||||||
fixtureToken = "AYATORI-TEST-ONLY-bao-token"
|
fixtureToken = "AYATORI-TEST-ONLY-bao-token"
|
||||||
kvDataKey = "data"
|
kvDataKey = "data"
|
||||||
|
kvVersionKey = "version"
|
||||||
)
|
)
|
||||||
|
|
||||||
func fixtureCredential(t *testing.T) application.ApplicationCredential {
|
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 {
|
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")
|
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")
|
t.Error("cannot encode fixture write response")
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -85,7 +86,7 @@ func TestCredentialReadbackMustConfirmTheWrite(t *testing.T) {
|
|||||||
}
|
}
|
||||||
response := map[string]any{kvDataKey: data}
|
response := map[string]any{kvDataKey: data}
|
||||||
if scenario != "missing metadata" {
|
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 {
|
if err := json.NewEncoder(w).Encode(map[string]any{kvDataKey: response}); err != nil {
|
||||||
t.Error("cannot encode fixture read response")
|
t.Error("cannot encode fixture read response")
|
||||||
|
|||||||
Reference in New Issue
Block a user