refactor: 集中显式注入并归位凭据领域规则
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
Copyright 2026.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package credential
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strconv"
|
||||
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
|
||||
)
|
||||
|
||||
var ErrApplicationCredentialInvalid = errors.New("application credential is invalid")
|
||||
|
||||
var applicationIdentifier = regexp.MustCompile(`^[a-z][a-z0-9_]{0,62}$`)
|
||||
|
||||
// ApplicationCredential 是内存中的应用连接凭据,不得放入 CR 或普通日志。
|
||||
// 它与 Instance 管理凭据分开,固定输出交付合同中的七键,不生成带密码的 URI。
|
||||
type ApplicationCredential struct {
|
||||
username string
|
||||
password string
|
||||
database string
|
||||
endpoint instance.Endpoint
|
||||
}
|
||||
|
||||
func NewApplicationCredential(username, password, database string, endpoint instance.Endpoint) (ApplicationCredential, error) {
|
||||
if !applicationIdentifier.MatchString(username) || !applicationIdentifier.MatchString(database) || password == "" {
|
||||
return ApplicationCredential{}, ErrApplicationCredentialInvalid
|
||||
}
|
||||
if endpoint.Validate() != nil {
|
||||
return ApplicationCredential{}, ErrApplicationCredentialInvalid
|
||||
}
|
||||
return ApplicationCredential{
|
||||
username: username,
|
||||
password: password,
|
||||
database: database,
|
||||
endpoint: endpoint,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GenerateApplicationCredential 仅供已获准首次创建凭据的供应步骤调用。
|
||||
// 不能在读取失败、写入结果不确定或重启后无条件重新调用。
|
||||
func GenerateApplicationCredential(username, database string, endpoint instance.Endpoint) (ApplicationCredential, error) {
|
||||
password := make([]byte, 32)
|
||||
rand.Read(password)
|
||||
return NewApplicationCredential(username, base64.RawURLEncoding.EncodeToString(password), database, endpoint)
|
||||
}
|
||||
|
||||
func (c ApplicationCredential) String() string { return "[redacted application credential]" }
|
||||
func (c ApplicationCredential) GoString() string { return c.String() }
|
||||
func (c ApplicationCredential) MarshalJSON() ([]byte, error) {
|
||||
return []byte(`"[redacted application credential]"`), nil
|
||||
}
|
||||
|
||||
// SecretData 只在凭据后端或数据库连接边界使用;返回值包含明文密码,禁止记录日志。
|
||||
// 每次返回独立 map,调用方不能修改已经构造的凭据。
|
||||
func (c ApplicationCredential) SecretData() map[string]any {
|
||||
endpoint := c.endpoint.Values()
|
||||
return map[string]any{
|
||||
"username": c.username,
|
||||
"password": c.password,
|
||||
"database": c.database,
|
||||
"host": endpoint.Host,
|
||||
"hostaddr": endpoint.HostAddr,
|
||||
"port": strconv.Itoa(endpoint.Port),
|
||||
"sslmode": string(endpoint.TLSMode),
|
||||
}
|
||||
}
|
||||
|
||||
func (c ApplicationCredential) Validate() error {
|
||||
_, err := NewApplicationCredential(c.username, c.password, c.database, c.endpoint)
|
||||
return err
|
||||
}
|
||||
|
||||
// MatchesTarget 只比较连接目标,不向用例暴露密码;管理库名不是应用连接目标的一部分。
|
||||
func (c ApplicationCredential) MatchesTarget(username, database string, endpoint instance.Endpoint) bool {
|
||||
actual, wanted := c.endpoint.Values(), endpoint.Values()
|
||||
return c.username == username && c.database == database && actual.Host == wanted.Host &&
|
||||
actual.HostAddr == wanted.HostAddr && actual.Port == wanted.Port && actual.TLSMode == wanted.TLSMode
|
||||
}
|
||||
|
||||
// ParseApplicationCredential 拒绝缺键、非字符串或非法连接参数,不回显后端内容。
|
||||
func ParseApplicationCredential(data map[string]any) (ApplicationCredential, error) {
|
||||
values := make(map[string]string, 7)
|
||||
for _, key := range []string{"username", "password", "database", "host", "hostaddr", "port", "sslmode"} {
|
||||
value, ok := data[key].(string)
|
||||
if !ok || value == "" {
|
||||
return ApplicationCredential{}, ErrApplicationCredentialInvalid
|
||||
}
|
||||
values[key] = value
|
||||
}
|
||||
port, err := strconv.Atoi(values["port"])
|
||||
if err != nil {
|
||||
return ApplicationCredential{}, ErrApplicationCredentialInvalid
|
||||
}
|
||||
endpoint, err := instance.NewEndpoint(instance.EndpointValues{
|
||||
Host: values["host"],
|
||||
HostAddr: values["hostaddr"],
|
||||
Port: port,
|
||||
ManagementDatabase: values["database"],
|
||||
TLSMode: instance.TLSMode(values["sslmode"]),
|
||||
})
|
||||
if err != nil {
|
||||
return ApplicationCredential{}, ErrApplicationCredentialInvalid
|
||||
}
|
||||
return NewApplicationCredential(values["username"], values["password"], values["database"], endpoint)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
Copyright 2026.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package credential_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
credentialdomain "git.ddupan.top/panxiao81/ayatori/internal/database/domain/credential"
|
||||
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
|
||||
)
|
||||
|
||||
func TestApplicationCredential(t *testing.T) {
|
||||
endpoint, err := instance.NewEndpoint(instance.EndpointValues{
|
||||
Host: "postgres.example", HostAddr: "192.0.2.1", Port: 5432,
|
||||
ManagementDatabase: "postgres", TLSMode: instance.TLSVerifyFull,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := credentialdomain.GenerateApplicationCredential("owner", "app", endpoint)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := credentialdomain.GenerateApplicationCredential("owner", "app", endpoint)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := first.SecretData()
|
||||
if len(data) != 7 || data["password"] == second.SecretData()["password"] || len(data["password"].(string)) != 43 {
|
||||
t.Fatal("expected seven keys and independent 256-bit passwords")
|
||||
}
|
||||
parsed, err := credentialdomain.ParseApplicationCredential(data)
|
||||
if err != nil || !maps.Equal(parsed.SecretData(), data) {
|
||||
t.Fatal("credential did not round trip")
|
||||
}
|
||||
encoded, err := json.Marshal(first)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, output := range []string{fmt.Sprint(first), fmt.Sprintf("%+v", first), fmt.Sprintf("%#v", first), string(encoded)} {
|
||||
if strings.Contains(output, data["password"].(string)) {
|
||||
t.Fatal("credential formatting leaked the password")
|
||||
}
|
||||
}
|
||||
data["password"] = "changed"
|
||||
if first.SecretData()["password"] == "changed" {
|
||||
t.Fatal("caller mutated credential")
|
||||
}
|
||||
for key := range data {
|
||||
invalid := maps.Clone(data)
|
||||
delete(invalid, key)
|
||||
if _, err := credentialdomain.ParseApplicationCredential(invalid); err == nil {
|
||||
t.Fatalf("accepted missing %s", key)
|
||||
}
|
||||
invalid[key] = 42
|
||||
if _, err := credentialdomain.ParseApplicationCredential(invalid); err == nil {
|
||||
t.Fatalf("accepted non-string %s", key)
|
||||
}
|
||||
}
|
||||
if (credentialdomain.ApplicationCredential{}).Validate() == nil {
|
||||
t.Fatal("accepted zero credential")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package credential
|
||||
|
||||
import (
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/binding"
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
|
||||
)
|
||||
|
||||
type Location struct {
|
||||
Mount string
|
||||
Path string
|
||||
}
|
||||
|
||||
// Phase 表达凭据准备进度,不依赖 Kubernetes Condition 的类型或 Reason。
|
||||
type Phase uint8
|
||||
|
||||
const (
|
||||
Pending Phase = iota
|
||||
Pinned
|
||||
Creating
|
||||
Prepared
|
||||
Conflict
|
||||
Unavailable
|
||||
Stopped
|
||||
InvalidTarget
|
||||
)
|
||||
|
||||
type State struct {
|
||||
Location *Location
|
||||
Version int64
|
||||
Phase Phase
|
||||
Message string
|
||||
}
|
||||
|
||||
func (s State) WithPhase(phase Phase, message string) State {
|
||||
s.Phase, s.Message = phase, message
|
||||
return s
|
||||
}
|
||||
|
||||
func (s State) Confirmed() bool { return s.Version > 0 }
|
||||
|
||||
// Created 只接受首次创建并回读得到的版本,不能把后续写入认作首次供应。
|
||||
func (s State) Created(version int64) (State, *Issue) {
|
||||
if version != 1 {
|
||||
return s, &Issue{Conflict, "凭据创建冲突或结果不确定;请核对固定位置的版本历史,未认领、覆盖或重新生成密码"}
|
||||
}
|
||||
s.Version = version
|
||||
return s.WithPhase(Prepared, "凭据已创建并回读确认;尚未创建 PostgreSQL 资源或交付给 Tenant"), nil
|
||||
}
|
||||
|
||||
// Resume 决定新一轮是否可以继续。未确认的创建不能靠读取成功认领。
|
||||
func (s State) Resume() (State, bool) {
|
||||
if s.Version != 0 {
|
||||
return s, true
|
||||
}
|
||||
switch s.Phase {
|
||||
case Conflict:
|
||||
return s, false
|
||||
case Creating:
|
||||
return s.WithPhase(Conflict, "凭据创建未留下成功确认;请核对固定位置与后端历史并人工处理,未重新生成密码"), false
|
||||
default:
|
||||
return s, true
|
||||
}
|
||||
}
|
||||
|
||||
func (s State) CheckLocation(configured Location) *Issue {
|
||||
if s.Location != nil && *s.Location != configured {
|
||||
return &Issue{Unavailable, "部署配置与固定凭据位置不一致;请恢复原 mount/path 配置,未迁移或改密"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Instance struct {
|
||||
binding.Instance
|
||||
Endpoint instance.Endpoint
|
||||
}
|
||||
|
||||
// Target 只包含供应资格所需事实,不含 resourceVersion、Conditions 或 repository 对象。
|
||||
type Target struct {
|
||||
Database binding.Database
|
||||
Tenant *binding.Tenant
|
||||
Instance *Instance
|
||||
DatabaseProtected bool
|
||||
TenantProtected bool
|
||||
}
|
||||
|
||||
type Issue struct {
|
||||
Phase Phase
|
||||
Message string
|
||||
}
|
||||
|
||||
func (t Target) RequiresPreparation() bool { return t.Database.Source == "Provision" }
|
||||
|
||||
func (t Target) CheckCredential(value ApplicationCredential) *Issue {
|
||||
if t.Instance == nil || !value.MatchesTarget(t.Database.LoginRole, t.Database.Name, t.Instance.Endpoint) {
|
||||
return &Issue{Conflict, "已确认凭据与当前 Instance/database/loginRole 不一致;请人工核实,未修改凭据"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t Target) Check() *Issue {
|
||||
database := t.Database
|
||||
if database.Deleting || database.Phase == binding.Deleting || database.Phase == "Released" {
|
||||
return &Issue{Stopped, "Database 正在删除或已释放;保留凭据与 finalizer,不执行供应或清理"}
|
||||
}
|
||||
if database.Tenant == nil || t.Tenant == nil || t.Tenant.Database == nil {
|
||||
return &Issue{Unavailable, "等待 Database 与 Tenant 双向绑定完成"}
|
||||
}
|
||||
if *database.Tenant != t.Tenant.Identity || *t.Tenant.Database != database.Identity {
|
||||
return &Issue{Conflict, "双向绑定的名称或 UID 不匹配,未创建凭据"}
|
||||
}
|
||||
if t.Tenant.Deleting || t.Tenant.Phase != binding.Bound || !t.DatabaseProtected || !t.TenantProtected {
|
||||
return &Issue{Stopped, "Tenant 未完成绑定、正在删除或缺少 finalizer 保护,未创建凭据"}
|
||||
}
|
||||
request, err := t.Tenant.Request.Resolve(t.Tenant.Identity)
|
||||
if err != nil || (request.Provision != nil && !database.MatchesProvision(request, t.Tenant.Identity)) || request.Name != database.Identity.Name {
|
||||
return &Issue{Conflict, "Tenant 申请与 Database 目标不一致,未创建凭据"}
|
||||
}
|
||||
if t.Instance == nil || database.InstanceUID == "" {
|
||||
return &Issue{Unavailable, "等待 Instance 与已记录的实例身份"}
|
||||
}
|
||||
if issue := t.Instance.Check(&database); issue != nil {
|
||||
phase := Unavailable
|
||||
if issue.Reason == binding.Conflict {
|
||||
phase = Conflict
|
||||
}
|
||||
return &Issue{phase, issue.Message}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package credential_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
credential "git.ddupan.top/panxiao81/ayatori/internal/database/domain/credential"
|
||||
)
|
||||
|
||||
func TestPreparationResume(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
phase credential.Phase
|
||||
version int64
|
||||
continueAllowed bool
|
||||
result credential.Phase
|
||||
}{
|
||||
{"尚未创建", credential.Pinned, 0, true, credential.Pinned},
|
||||
{"依赖恢复", credential.Unavailable, 0, true, credential.Unavailable},
|
||||
{"中断创建", credential.Creating, 0, false, credential.Conflict},
|
||||
{"未确认冲突", credential.Conflict, 0, false, credential.Conflict},
|
||||
{"已确认后读取失败", credential.Unavailable, 1, true, credential.Unavailable},
|
||||
{"已确认后冲突重验", credential.Conflict, 1, true, credential.Conflict},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
original := credential.State{Phase: test.phase, Version: test.version, Message: "保留原诊断"}
|
||||
state, allowed := original.Resume()
|
||||
if allowed != test.continueAllowed || state.Phase != test.result || state.Version != original.Version {
|
||||
t.Fatal("恢复判定或确认版本发生变化")
|
||||
}
|
||||
if test.phase == credential.Conflict && state.Message != original.Message {
|
||||
t.Fatal("冲突重入应保留原诊断")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreparationLocationAndConfirmation(t *testing.T) {
|
||||
location := credential.Location{Mount: "applications", Path: "database/uid"}
|
||||
state := credential.State{Location: &location, Phase: credential.Creating}
|
||||
if issue := state.CheckLocation(location); issue != nil {
|
||||
t.Fatal("固定位置不应被拒绝")
|
||||
}
|
||||
if issue := state.CheckLocation(credential.Location{Mount: "other", Path: location.Path}); issue == nil || issue.Phase != credential.Unavailable {
|
||||
t.Fatal("配置变化必须停止,不迁移已固定位置")
|
||||
}
|
||||
for _, version := range []int64{0, -1, 2} {
|
||||
result, issue := state.Created(version)
|
||||
if issue == nil || issue.Phase != credential.Conflict || result.Confirmed() {
|
||||
t.Fatal("错误版本不得确认创建")
|
||||
}
|
||||
}
|
||||
result, issue := state.Created(1)
|
||||
if issue != nil || !result.Confirmed() || result.Phase != credential.Prepared || result.Location != state.Location {
|
||||
t.Fatal("首次写入回读应确认并保留位置")
|
||||
}
|
||||
if state.Version != 0 {
|
||||
t.Fatal("领域判定不得修改调用方的旧状态")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package credential_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/binding"
|
||||
credential "git.ddupan.top/panxiao81/ayatori/internal/database/domain/credential"
|
||||
)
|
||||
|
||||
const (
|
||||
targetTestApplication = "sampleapp"
|
||||
targetTestInstance = "test-instance"
|
||||
)
|
||||
|
||||
func TestPreparationTarget(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
change func(*credential.Target)
|
||||
want credential.Phase
|
||||
}{
|
||||
{"完整绑定", func(*credential.Target) {}, credential.Pending},
|
||||
{"单向绑定", func(target *credential.Target) { target.Tenant.Database = nil }, credential.Unavailable},
|
||||
{"旧租户身份", func(target *credential.Target) { target.Tenant.Identity.UID = "new" }, credential.Conflict},
|
||||
{"旧实例身份", func(target *credential.Target) { target.Instance.Identity.UID = "new" }, credential.Conflict},
|
||||
{"资源删除", func(target *credential.Target) { target.Database.Deleting = true }, credential.Stopped},
|
||||
{"申请删除", func(target *credential.Target) { target.Tenant.Deleting = true }, credential.Stopped},
|
||||
{"Released", func(target *credential.Target) { target.Database.Phase = "Released" }, credential.Stopped},
|
||||
{"缺少保护", func(target *credential.Target) { target.DatabaseProtected = false }, credential.Stopped},
|
||||
{"实例未就绪", func(target *credential.Target) { target.Instance.Ready = false }, credential.Unavailable},
|
||||
{"目标变化", func(target *credential.Target) { target.Database.LoginRole = "other" }, credential.Conflict},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
tenantID := binding.TenantIdentity{Namespace: "apps", Name: targetTestApplication, UID: "tenant"}
|
||||
databaseID := binding.Identity{Name: binding.DynamicDatabaseName(tenantID.UID), UID: "database"}
|
||||
target := credential.Target{
|
||||
Database: binding.Database{Identity: databaseID, Tenant: &tenantID, Instance: targetTestInstance, InstanceUID: "instance-id", Name: targetTestApplication, LoginRole: targetTestApplication, Source: "Provision"},
|
||||
Tenant: &binding.Tenant{Identity: tenantID, Database: &databaseID, Phase: binding.Bound, Request: binding.Request{Provision: &binding.ProvisionRequest{Instance: targetTestInstance}}},
|
||||
Instance: &credential.Instance{Identity: binding.Identity{Name: targetTestInstance, UID: "instance-id"}, Ready: true},
|
||||
DatabaseProtected: true, TenantProtected: true,
|
||||
}
|
||||
test.change(&target)
|
||||
issue := target.Check()
|
||||
if test.want == credential.Pending {
|
||||
if issue != nil {
|
||||
t.Fatalf("有效绑定被拒绝: %s", issue.Message)
|
||||
}
|
||||
} else if issue == nil || issue.Phase != test.want {
|
||||
t.Fatal("领域资格判定不符")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user