diff --git a/Makefile b/Makefile index 750f4c7..05b9541 100644 --- a/Makefile +++ b/Makefile @@ -68,7 +68,7 @@ lint: golangci-lint ## Run golangci-lint linter "$(GOLANGCI_LINT)" run .PHONY: test-database-integration -test-database-integration: setup-envtest ## 使用临时 API server 与独立 PostgreSQL 容器验证凭据读取和连接更新。 +test-database-integration: setup-envtest ## 使用临时 API server、PostgreSQL 与 OpenBao 容器验证 Database 后端。 KUBEBUILDER_ASSETS="$(shell "$(ENVTEST)" use $(ENVTEST_K8S_VERSION) --bin-dir "$(LOCALBIN)" -p path)" go test -tags=integration -race -count=1 ./internal/database/... .PHONY: lint-database-integration diff --git a/docs/database/README.md b/docs/database/README.md index 0113e2a..468465b 100644 --- a/docs/database/README.md +++ b/docs/database/README.md @@ -151,6 +151,27 @@ Instance 删除首先释放本地连接并撤销 Ready。任何引用它的 Data 轮换、删除、跨 namespace 拒绝和 watch。API 测试另覆盖写入版本冲突、幂等、新 reconciler 恢复与引用删除保护。完整 DBaaS 仍需供应/导入、OpenBao/ESO、Retain/Delete 集成验收。 +## 应用凭据存储切片 + +`adapter/openbao` 使用官方 Go SDK `api/v2 v2.7.0` 的 KV v2 API,只有创建和读取, +不维护 registry、不覆盖已有密码。动态路径由固定前缀与 Database UID 组成;所有访问都校验 +配置前缀,已有导入位置也不能绕过 controller 的凭据权限范围。 + +创建使用 CAS=0,随后回读七键和版本 1;已有值或软删除历史报冲突。关闭 SDK 自动重试, +写入响应丢失、回读失败或内容变化均返回不确定结果,上层不得生成第二份密码或自动认领。 +`Read` 只适用于调用方已确认关联的路径,读取成功本身不是管理权证据。错误不传播 SDK +响应体;内存凭据的普通格式化及 JSON 输出均脱敏,明确的 `SecretData` 才返回明文七键。 + +依据官方 [KV v2 CAS 合同](https://github.com/openbao/openbao/blob/main/internal/builtin/logical/kv/path_data.go) +与 [Go SDK](https://github.com/openbao/openbao/tree/main/api)。`make test-database-integration` +现包含独立 OpenBao dev 容器,固定摘要、随机回环端口、无持久卷,不接受外部地址。 +真实后端覆盖创建/回读、并发唯一创建、重建适配器读取、软删除冲突、固定前缀 token +拒绝管理路径,以及成功写入后丢失响应;HTTP 故障测试补充不重试和错误脱敏。 + +这一切片尚未接入 manager:Kubernetes auth/token 生命周期、Database 状态中的稳定位置和 +已确认步骤、供应 service/controller、PostgreSQL 创建以及 ESO 交付仍未完成。 +测试 token 只用于临时 fixture,不是生产静态 token 配置接口。现有绑定不会触发外部写入。 + ## 设计入口 - [系统规格](specification.md):规范性行为与验收标准; diff --git a/docs/database/deployment.md b/docs/database/deployment.md index 9a08110..470dae5 100644 --- a/docs/database/deployment.md +++ b/docs/database/deployment.md @@ -1,16 +1,16 @@ # 部署与配置 -> 本页迁入作为 Database 模块的目标部署合同。Ayatori manager flags、manifests 与发布装配尚未 -> 实现;当前行为以修订后的系统规格为准,本页不能直接用于部署。 +> 本页区分已实现的 Instance 观测配置与尚未接入的供应/交付目标合同。 +> 完整 Database 服务仍不可部署使用;当前可执行入口见 [模块说明](README.md)。 | 项目 | 内容 | | --- | --- | | 状态 | Review | | 环境 | homelab Kubernetes + 外部 PostgreSQL/OpenBao | -| 最后更新 | 2026-09-24 | +| 最后更新 | 2026-09-25 | -本文定义 v1alpha1 的运行依赖、启动顺序和部署级配置。当前 manifests 尚未实现这些 -配置,示例是后续实现合同,不可直接用于现有脚手架。 +本文定义 v1alpha1 的运行依赖、启动顺序和部署级配置。Instance 观测已接入 manager; +OpenBao、ESO 与完整供应装配仍是后续实现合同。 ## 依赖与顺序 @@ -30,7 +30,11 @@ ## Controller 配置合同 -以下是尚待实现的部署配置合同,凭据定位随三资源 API 继续细化。controller 使用这些 CLI flags。 +当前 manager 支持 `--database-secret-namespace`(默认 `POD_NAMESPACE`,为空则停用 +Instance 观测)与 `--database-root-cert`(公开 PostgreSQL CA PEM 路径)。Deployment +通过 downward API 获取 namespace,Secret 权限由该 namespace 的 Role 授予。 + +以下是尚待实现的供应/交付配置合同,不表示当前 manager 接受这些 CLI flags。 必填项缺失、路径无效或 duration 不为正数时,进程必须在启动 manager 前失败; 不得等到 reconcile 时才逐个资源报告配置错误。 @@ -44,7 +48,7 @@ | `--openbao-service-account-token-path` | `/var/run/secrets/kubernetes.io/serviceaccount/token` | Kubernetes auth 使用的投射 token 文件 | | `--openbao-tenant-base-path` | 默认 `postgresql-tenants` | controller 专属 mount-relative 前缀 | | `--external-secret-store-name` | 必填 | controller 创建的 ExternalSecret 固定引用 | -| `--postgresql-ca-bundle-path` | PostgreSQL TLS 模式必填 | 只读 PEM trust bundle,不含私钥 | +| `--database-root-cert` | 已实现 | 只读 PEM trust bundle,不含私钥;沿用 Instance 连接配置 | | `--reconcile-timeout` | `30s` | 单轮 reconcile 中外部操作的总期限,必须大于零 | address 必须是绝对 `http` 或 `https` URL,不允许 userinfo、query 或 fragment,末尾 `/` @@ -54,7 +58,9 @@ API 层。生产环境的 `--openbao-address` 必须使用 HTTPS;HTTP 只用 Tenant 不能选择任意凭据路径。凭据必须能随 Database 保留并安全交付给被授权的新 Tenant; 原 `//` 定位规则不再直接作为新 API 合同。 -稳定位置与导入关联方式待 API 评审;consumer URL 仍使用无认证信息的 KV v2 API URL。 +动态供应位置使用 `/`;导入使用 Database 的显式 credentialRef, +不要求搬迁已有凭据。供应流程须先记录原 mount/path,不能在配置变化后重新推导位置。 +consumer URL 仍使用无认证信息的 KV v2 API URL。 base path 必须是合法 mount-relative path,不以 `/` 开头且不包含空段、`.`、`..`、 `data`/`metadata` API 层。ExternalSecret 固定命名为 @@ -77,9 +83,10 @@ base path 必须是合法 mount-relative path,不以 `/` 开头且不包含空 - `Delete` 时禁止连接、终止目标 database session、删除已验证归属的 database/role。 部分 PostgreSQL 操作天然要求较高权限,尤其终止其他 session 和安装某些 extension。 -应优先使用 PostgreSQL 预定义角色、受控 SECURITY DEFINER 管理函数或限定数据库的 -授权;任何不得不使用 superuser 的 extension 都必须按实例单独记录,不得扩大默认 -controller 权限。最终可执行 SQL grant 将随 PostgreSQL adapter 集成测试固化。 +第一版使用原生非 superuser 的 CREATEDB/CREATEROLE 方案,不引入 SECURITY DEFINER +管理接口。对自行创建的 owner 显式建立 SET membership,再以 owner 管理 ACL 与扩展; +已有对象仍须逐资源核实授权,不能凭基础属性接管。需要 superuser 的扩展不能扩大 controller +权限。真实权限矩阵见 [Instance 原生管理观测](README.md#instance-原生管理观测)。 ## OpenBao 与 ESO diff --git a/go.mod b/go.mod index 73f301d..3b0931a 100644 --- a/go.mod +++ b/go.mod @@ -4,10 +4,12 @@ go 1.27.1 require ( github.com/jackc/pgx/v5 v5.11.0 + github.com/openbao/openbao/api/v2 v2.7.0 k8s.io/api v0.37.0 k8s.io/apimachinery v0.37.0 k8s.io/client-go v0.37.0 sigs.k8s.io/controller-runtime v0.25.0 + sigs.k8s.io/yaml v1.6.0 ) require ( @@ -24,6 +26,7 @@ require ( github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.1 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/zapr v1.3.0 // indirect @@ -41,15 +44,25 @@ require ( github.com/go-openapi/swag/stringutils v0.27.1 // indirect github.com/go-openapi/swag/typeutils v0.27.1 // indirect github.com/go-openapi/swag/yamlutils v0.27.1 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/google/cel-go v0.29.2 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect + github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect + github.com/hashicorp/go-sockaddr v1.0.7 // indirect + github.com/hashicorp/hcl v1.0.1-vault-7 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect @@ -58,6 +71,7 @@ require ( github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.70.0 // indirect github.com/prometheus/procfs v0.21.1 // indirect + github.com/ryanuber/go-glob v1.0.0 // indirect github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/x448/float16 v0.8.4 // indirect @@ -75,7 +89,7 @@ require ( go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect - golang.org/x/net v0.57.0 // indirect + golang.org/x/net v0.58.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect @@ -100,5 +114,4 @@ require ( sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.4.2 // indirect - sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index 4c42cc0..876f46b 100644 --- a/go.sum +++ b/go.sum @@ -23,12 +23,16 @@ github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8 github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ= github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -72,6 +76,10 @@ github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAg github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/cel-go v0.29.2 h1:ZtDxkeiMmz0mxbKDYiNkE5Lk7V5edMRcaaDf2jX002k= @@ -89,6 +97,25 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= +github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= +github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= +github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= +github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -105,6 +132,12 @@ github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2o github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= +github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -117,6 +150,8 @@ github.com/onsi/ginkgo/v2 v2.27.4 h1:fcEcQW/A++6aZAZQNUmNjvA9PSOzefMJBerHJ4t8v8Y github.com/onsi/ginkgo/v2 v2.27.4/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q= github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= +github.com/openbao/openbao/api/v2 v2.7.0 h1:3CD1l3tr39nQraCgFGAWA5vYvPFzZoZrt3NL7DMQKAc= +github.com/openbao/openbao/api/v2 v2.7.0/go.mod h1:uXbMoyH2pjSvNyTepinUvLde8pOJB82EuhUCfOKnKbo= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -131,6 +166,8 @@ github.com/prometheus/common v0.70.0/go.mod h1:S/SFasQmgGiYH6C81LKCtYa8QACgthGg5 github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= +github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= @@ -141,8 +178,8 @@ github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -180,8 +217,8 @@ golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJk golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= -golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= -golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= diff --git a/internal/database/adapter/openbao/credentials.go b/internal/database/adapter/openbao/credentials.go new file mode 100644 index 0000000..ff80459 --- /dev/null +++ b/internal/database/adapter/openbao/credentials.go @@ -0,0 +1,140 @@ +/* +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 openbao 通过官方 SDK 适配应用凭据,不保存资源归属或重建供应状态。 +package openbao + +import ( + "context" + "errors" + "maps" + "net/http" + "regexp" + "slices" + "strings" + + bao "github.com/openbao/openbao/api/v2" + + "git.ddupan.top/panxiao81/ayatori/internal/database/application" +) + +var ( + ErrInvalidLocation = errors.New("credential location is outside the configured scope") + ErrUnavailable = errors.New("credential backend unavailable") + ErrNotFound = errors.New("application credential not found") + ErrConflict = errors.New("credential creation requires manual conflict resolution") + ErrUncertain = errors.New("credential creation outcome is uncertain; manual resolution required") +) + +var pathSegment = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) + +// Credentials 使用独立的 SDK client;认证与短期 token 生命周期由部署装配负责。 +// 本适配器既不自动认领已有值,也不提供覆盖、轮换或删除操作。 +type Credentials struct { + kv *bao.KVv2 + basePath string +} + +// NewCredentials 不登录、不读取环境 token。调用方必须提供专用的已认证 client。 +// 禁用 SDK 写入重试,防止第一次结果丢失后被 CAS 错误掩盖。 +func NewCredentials(client *bao.Client, mount, basePath string) (*Credentials, error) { + if client == nil || !validPath(mount) || !validPath(basePath) { + return nil, ErrInvalidLocation + } + client.SetMaxRetries(0) + return &Credentials{kv: client.KVv2(mount), basePath: basePath}, nil +} + +func validPath(value string) bool { + for segment := range strings.SplitSeq(value, "/") { + if !pathSegment.MatchString(segment) || segment == "data" || segment == "metadata" { + return false + } + } + return true +} + +// ProvisionPath 只按 Database UID 定位;调用方须先持久化位置,再执行外部写入。 +func (c *Credentials) ProvisionPath(databaseUID string) (string, error) { + if !pathSegment.MatchString(databaseUID) { + return "", ErrInvalidLocation + } + return c.basePath + "/" + databaseUID, nil +} + +func (c *Credentials) accepts(path string) bool { + return validPath(path) && strings.HasPrefix(path, c.basePath+"/") +} + +// Read 只读取调用方已确认关联的路径;成功读取不构成对既有凭据的自动认领。 +func (c *Credentials) Read(ctx context.Context, path string) (application.ApplicationCredential, error) { + if !c.accepts(path) { + return application.ApplicationCredential{}, ErrInvalidLocation + } + secret, err := c.kv.Get(ctx, path) + if errors.Is(err, bao.ErrSecretNotFound) { + return application.ApplicationCredential{}, ErrNotFound + } + if err != nil { + return application.ApplicationCredential{}, ErrUnavailable + } + if secret == nil || secret.Data == nil { + return application.ApplicationCredential{}, ErrNotFound + } + return application.ParseApplicationCredential(secret.Data) +} + +// Create 只创建从未存在过的路径,并验证回读七键与提交值完全一致。 +// 任何不确定写入都不返回凭据;上层必须停止供应并持久化冲突,不能重新生成密码。 +func (c *Credentials) Create(ctx context.Context, path string, credential application.ApplicationCredential) error { + if !c.accepts(path) { + return ErrInvalidLocation + } + if err := credential.Validate(); err != nil { + return err + } + if ctx.Err() != nil { + return ErrUnavailable + } + data := credential.SecretData() + created, err := c.kv.Put(ctx, path, data, bao.WithCheckAndSet(0)) + if err != nil { + // 明确的认证/权限拒绝没有发生写入,可以等待依赖恢复。 + // SDK 的原始错误可能携带路径及响应体,不向外传播。 + if response, ok := errors.AsType[*bao.ResponseError](err); ok { + switch response.StatusCode { + case http.StatusUnauthorized, http.StatusForbidden: + return ErrUnavailable + case http.StatusBadRequest: + if slices.Contains(response.Errors, "check-and-set parameter did not match the current version") { + return ErrConflict + } + } + } + return ErrUncertain + } + if created == nil || created.VersionMetadata == nil || created.VersionMetadata.Version != 1 { + return ErrUncertain + } + observed, err := c.kv.Get(ctx, path) + if err != nil || observed == nil || observed.VersionMetadata == nil || observed.VersionMetadata.Version != 1 { + return ErrUncertain + } + if !maps.Equal(data, observed.Data) { + return ErrUncertain + } + return nil +} diff --git a/internal/database/adapter/openbao/credentials_integration_test.go b/internal/database/adapter/openbao/credentials_integration_test.go new file mode 100644 index 0000000..52817ec --- /dev/null +++ b/internal/database/adapter/openbao/credentials_integration_test.go @@ -0,0 +1,187 @@ +//go:build integration + +/* +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 openbao_test + +import ( + "context" + "errors" + "maps" + "net/http" + "net/http/httptest" + "net/http/httputil" + "net/url" + "os/exec" + "regexp" + "strings" + "sync" + "testing" + "time" + + bao "github.com/openbao/openbao/api/v2" + + "git.ddupan.top/panxiao81/ayatori/internal/database/adapter/openbao" +) + +// 只连接本测试创建的无持久卷 dev server,不接受生产地址或环境 token。 +func baoFixture(t *testing.T) *bao.Client { + t.Helper() + ctx, cancel := context.WithTimeout(t.Context(), time.Minute) + defer cancel() + const image = "openbao/openbao@sha256:5b2486ab0fb90bbc788cc345b0a08616dfb375873ee8be5df3a2fd4d378a67e0" + output, err := exec.CommandContext(ctx, "docker", "run", "--rm", "-d", "-p", "127.0.0.1::8200", + image, "server", "-dev", "-dev-root-token-id="+fixtureToken, "-dev-listen-address=0.0.0.0:8200").Output() + if err != nil { + t.Fatal("cannot start isolated OpenBao fixture") + } + id := strings.TrimSpace(string(output)) + if !regexp.MustCompile(`^[a-f0-9]{64}$`).MatchString(id) { + t.Fatal("unexpected fixture container ID") + } + t.Cleanup(func() { + cleanup, stop := context.WithTimeout(context.Background(), 30*time.Second) + defer stop() + if exec.CommandContext(cleanup, "docker", "rm", "-f", id).Run() != nil { + t.Error("OpenBao fixture cleanup failed") + } + }) + output, err = exec.CommandContext(ctx, "docker", "inspect", "--format", + `{{(index (index .NetworkSettings.Ports "8200/tcp") 0).HostPort}}`, id).Output() + if err != nil { + t.Fatal("cannot inspect fixture port") + } + client := fixtureClient(t, "http://127.0.0.1:"+strings.TrimSpace(string(output))) + client.SetMaxRetries(0) + for { + if _, err := client.Sys().HealthWithContext(ctx); err == nil { + return client + } + select { + case <-ctx.Done(): + t.Fatal("OpenBao fixture startup timed out") + case <-time.After(100 * time.Millisecond): + } + } +} + +func TestCredentialConcurrentCreateWithRealOpenBao(t *testing.T) { + root := baoFixture(t) + store := fixtureStore(t, root) + credential := fixtureCredential(t) + results := make(chan error, 2) + var workers sync.WaitGroup + for range 2 { + workers.Go(func() { results <- store.Create(t.Context(), credentialPath, credential) }) + } + workers.Wait() + close(results) + succeeded, conflicted := 0, 0 + for err := range results { + switch err { + case nil: + succeeded++ + case openbao.ErrConflict: + conflicted++ + default: + t.Fatal("unexpected concurrent create result") + } + } + if succeeded != 1 || conflicted != 1 { + t.Fatal("CAS must allow exactly one creator") + } +} + +func TestCredentialLostWriteResponseWithRealOpenBao(t *testing.T) { + root := baoFixture(t) + address, err := url.Parse(root.Address()) + if err != nil { + t.Fatal("invalid fixture address") + } + proxy := httputil.NewSingleHostReverseProxy(address) + proxy.ModifyResponse = func(response *http.Response) error { + if response.Request.Method == http.MethodPut && response.StatusCode == http.StatusOK { + return errors.New("fixture drops successful write response") + } + return nil + } + proxy.ErrorHandler = func(w http.ResponseWriter, _ *http.Request, _ error) { + w.WriteHeader(http.StatusBadGateway) + } + server := httptest.NewServer(proxy) + defer server.Close() + store := fixtureStore(t, fixtureClient(t, server.URL)) + credential := fixtureCredential(t) + if err := store.Create(t.Context(), credentialPath, credential); err != openbao.ErrUncertain { + t.Fatal("lost response must stop provisioning") + } + confirmed, err := root.KVv2("secret").Get(t.Context(), credentialPath) + if err != nil || !maps.Equal(confirmed.Data, credential.SecretData()) || confirmed.VersionMetadata.Version != 1 { + t.Fatal("fault injection did not preserve the original write") + } + if err := fixtureStore(t, root).Create(t.Context(), credentialPath, credential); err != openbao.ErrConflict { + t.Fatal("restart must not adopt an unconfirmed write") + } +} + +func TestCredentialsWithRealOpenBao(t *testing.T) { + root := baoFixture(t) + ctx := t.Context() + // root 仅用于 fixture 装配;实际读写使用固定前缀的短期 token。 + policy := `path "secret/data/applications/*" { capabilities = ["create", "update", "read"] }` + if err := root.Sys().PutPolicyWithContext(ctx, "application-fixture", policy); err != nil { + t.Fatal("cannot configure fixture policy") + } + secret, err := root.Auth().Token().CreateWithContext(ctx, &bao.TokenCreateRequest{ + Policies: []string{"application-fixture"}, NoDefaultPolicy: true, TTL: "5m", + }) + if err != nil { + t.Fatal("cannot create scoped fixture token") + } + client := fixtureClient(t, root.Address()) + client.SetToken(secret.Auth.ClientToken) + store := fixtureStore(t, client) + credential := fixtureCredential(t) + if err := store.Create(ctx, credentialPath, credential); err != nil { + t.Fatal(err) + } + // 重建适配器读取已确认路径;重复 Create 仍报冲突,不把读取当作认领。 + restarted := fixtureStore(t, client) + observed, err := restarted.Read(ctx, credentialPath) + if err != nil || !maps.Equal(observed.SecretData(), credential.SecretData()) { + t.Fatal("confirmed credential was not preserved across adapter restart") + } + if err := restarted.Create(ctx, credentialPath, credential); !errors.Is(err, openbao.ErrConflict) { + t.Fatal("existing credential must conflict even if contents match") + } + metadata, err := root.KVv2("secret").GetMetadata(ctx, credentialPath) + if err != nil || metadata.CurrentVersion != 1 { + t.Fatal("duplicate create changed credential version") + } + if _, err := client.KVv2("secret").Get(ctx, "management/instance"); err == nil { + t.Fatal("scoped token accessed management credentials") + } + if err := root.KVv2("secret").Delete(ctx, credentialPath); err != nil { + t.Fatal("cannot soft-delete fixture credential") + } + if _, err := store.Read(ctx, credentialPath); err != openbao.ErrNotFound { + t.Fatal("soft-deleted credential must not be usable") + } + if err := store.Create(ctx, credentialPath, credential); err != openbao.ErrConflict { + t.Fatal("soft-deleted credential must not be recreated") + } +} diff --git a/internal/database/adapter/openbao/credentials_test.go b/internal/database/adapter/openbao/credentials_test.go new file mode 100644 index 0000000..26cce76 --- /dev/null +++ b/internal/database/adapter/openbao/credentials_test.go @@ -0,0 +1,190 @@ +/* +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 openbao_test + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + bao "github.com/openbao/openbao/api/v2" + + "git.ddupan.top/panxiao81/ayatori/internal/database/adapter/openbao" + "git.ddupan.top/panxiao81/ayatori/internal/database/application" +) + +const ( + credentialPath = "applications/database-uid" + fixturePassword = "AYATORI-TEST-ONLY-application-password" + fixtureToken = "AYATORI-TEST-ONLY-bao-token" + kvDataKey = "data" +) + +func fixtureCredential(t *testing.T) application.ApplicationCredential { + t.Helper() + credential, err := application.ParseApplicationCredential(map[string]any{ + "username": "app_owner", "password": fixturePassword, "database": "app", + "host": "postgres.example", "hostaddr": "192.0.2.1", "port": "5432", "sslmode": "verify-full", + }) + if err != nil { + t.Fatal(err) + } + return credential +} + +func TestCredentialReadbackMustConfirmTheWrite(t *testing.T) { + for _, scenario := range []string{"read failure", "changed version", "changed password", "missing metadata"} { + t.Run(scenario, func(t *testing.T) { + credential := fixtureCredential(t) + var writes atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPut { + writes.Add(1) + var request struct { + Options struct { + CAS *int `json:"cas"` + } `json:"options"` + } + 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 { + t.Error("cannot encode fixture write response") + } + return + } + if scenario == "read failure" { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + data := credential.SecretData() + version := 1 + if scenario == "changed version" { + version = 2 + } + if scenario == "changed password" { + data["password"] = "modified" + } + response := map[string]any{kvDataKey: data} + if scenario != "missing metadata" { + response["metadata"] = map[string]any{"version": version} + } + if err := json.NewEncoder(w).Encode(map[string]any{kvDataKey: response}); err != nil { + t.Error("cannot encode fixture read response") + } + })) + defer server.Close() + store := fixtureStore(t, fixtureClient(t, server.URL)) + if err := store.Create(t.Context(), credentialPath, credential); err != openbao.ErrUncertain || writes.Load() != 1 { + t.Fatal("unconfirmed readback must stop after one write") + } + }) + } +} + +func fixtureClient(t *testing.T, address string) *bao.Client { + t.Helper() + config := bao.DefaultConfig() + config.Address = address + client, err := bao.NewClient(config) + if err != nil { + t.Fatal("cannot construct fixture client") + } + client.SetToken(fixtureToken) + return client +} + +func fixtureStore(t *testing.T, client *bao.Client) *openbao.Credentials { + t.Helper() + store, err := openbao.NewCredentials(client, "secret", "applications") + if err != nil { + t.Fatal(err) + } + return store +} + +func TestCredentialLocationScope(t *testing.T) { + client := fixtureClient(t, "http://127.0.0.1:1") + store := fixtureStore(t, client) + path, err := store.ProvisionPath("database-uid") + if err != nil || path != credentialPath { + t.Fatal("unexpected stable location") + } + for _, path := range []string{"", "/absolute", "applications", "applications-other/key", "applications/../management", "applications/%2e%2e/key", "applications//key", "applications/data/key"} { + if _, err := store.Read(t.Context(), path); !errors.Is(err, openbao.ErrInvalidLocation) { + t.Fatal("accepted invalid location") + } + if err := store.Create(t.Context(), path, fixtureCredential(t)); !errors.Is(err, openbao.ErrInvalidLocation) { + t.Fatal("accepted invalid create location") + } + } + for _, uid := range []string{"", "../key", "a/b", "a?b"} { + if _, err := store.ProvisionPath(uid); err == nil { + t.Fatal("accepted invalid UID") + } + } + for _, invalid := range []string{"", "data", "metadata", "../secret", "secret/", "secret?query"} { + if _, err := openbao.NewCredentials(client, invalid, "applications"); err == nil { + t.Fatal("accepted invalid mount") + } + if _, err := openbao.NewCredentials(client, "secret", invalid); err == nil { + t.Fatal("accepted invalid base path") + } + } +} + +func TestCredentialWriteFailureDoesNotRetryOrLeak(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests.Add(1) + http.Error(w, fixturePassword+fixtureToken, http.StatusInternalServerError) + })) + defer server.Close() + store := fixtureStore(t, fixtureClient(t, server.URL)) + if err := store.Create(t.Context(), credentialPath, fixtureCredential(t)); err != openbao.ErrUncertain { + t.Fatal("write error must be a redacted uncertain outcome") + } + if requests.Load() != 1 { + t.Fatal("SDK retried an uncertain write") + } + if _, err := store.Read(t.Context(), credentialPath); err != openbao.ErrUnavailable { + t.Fatal("read error must be redacted") + } + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if err := store.Create(ctx, credentialPath, fixtureCredential(t)); err != openbao.ErrUnavailable || requests.Load() != 2 { + t.Fatal("canceled operation must not write") + } +} + +func TestCredentialWriteDeniedBeforeExecution(t *testing.T) { + for _, status := range []int{http.StatusUnauthorized, http.StatusForbidden} { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, fixtureToken, status) + })) + store := fixtureStore(t, fixtureClient(t, server.URL)) + err := store.Create(t.Context(), credentialPath, fixtureCredential(t)) + server.Close() + if err != openbao.ErrUnavailable { + t.Fatalf("status %d: definite rejection should wait for dependency recovery, got %v", status, err) + } + } +} diff --git a/internal/database/application/application_credential.go b/internal/database/application/application_credential.go new file mode 100644 index 0000000..f1b67b7 --- /dev/null +++ b/internal/database/application/application_credential.go @@ -0,0 +1,116 @@ +/* +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 application + +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 +} + +// 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) +} diff --git a/internal/database/application/application_credential_test.go b/internal/database/application/application_credential_test.go new file mode 100644 index 0000000..c0e5f0b --- /dev/null +++ b/internal/database/application/application_credential_test.go @@ -0,0 +1,81 @@ +/* +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 application_test + +import ( + "encoding/json" + "fmt" + "maps" + "strings" + "testing" + + "git.ddupan.top/panxiao81/ayatori/internal/database/application" + "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 := application.GenerateApplicationCredential("owner", "app", endpoint) + if err != nil { + t.Fatal(err) + } + second, err := application.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 := application.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 := application.ParseApplicationCredential(invalid); err == nil { + t.Fatalf("accepted missing %s", key) + } + invalid[key] = 42 + if _, err := application.ParseApplicationCredential(invalid); err == nil { + t.Fatalf("accepted non-string %s", key) + } + } + if (application.ApplicationCredential{}).Validate() == nil { + t.Fatal("accepted zero credential") + } +}