diff --git a/AGENTS.md b/AGENTS.md index 20d05560..bc89741f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -151,6 +151,6 @@ - 隧道由 `scripts/keep-upstream-tunnel.ps1` 自动重连,并通过 `scripts/install-upstream-tunnel-task.ps1 -SourceKey <来源标识>` 安装。数据库凭据不得作为隧道参数;每台候选中继开发机使用独立 SSH 私钥,不得多人共享私钥。 - 每个来源最多安排 1 台主中继和 1 台备用中继;同一来源同一时刻只允许一个远端监听成功,备用机低频重试。隧道中断时页面继续读取该来源旧快照,禁止绕过缓存直接重试上游。 - 系统数据库的启动连接只允许存在于服务器私有 `runtime/.env`;超级管理员和普通管理员可在“数据库管理”中修改,但保存前必须验证 TLS、系统必需表和用户数据,并在持久化 `storage` 中生成私有备份。 -- 云数据库连接与系统数据库配置必须分开。显式开启 `UPSTREAM_STORED_CONFIG_ENABLED` 后,云数据库密码使用当前 `APP_KEY` 加密保存到系统数据库的 `system_database_connections`;真实值仍不得进入代码、提交、PR、共享文档或日志。共享系统库的开发环境禁止复制生产 `APP_KEY`,本机直连测试必须显式开启 `UPSTREAM_LOCAL_CONFIG_ENABLED`,并通过私有 `DB_<来源>_*` 环境变量提供完整连接,不得用开发机密钥改写共享密码。中继开发机只保留已收紧 ACL 的隧道端点配置,不保存数据库密码。 +- 云数据库连接与系统数据库配置必须分开。显式开启 `UPSTREAM_STORED_CONFIG_ENABLED` 后,云数据库密码在 `system_database_connections` 中同时保留旧版 `APP_KEY` 密文用于紧急回滚,并使用系统数据库登录密码派生的密钥写入 `shared_password` 供受信任开发机共享;真实值仍不得进入代码、提交、PR、共享文档或日志。填写同一套系统数据库参数后必须自动关闭 `UPSTREAM_LOCAL_CONFIG_ENABLED` 并优先读取共享配置,禁止为了共享云数据库密码而复制生产 `APP_KEY`。只有隔离排错或尚未迁移的旧环境才允许显式开启本机覆盖,并通过私有 `DB_<来源>_*` 环境变量提供完整连接。系统数据库密码轮换前必须先完成共享云数据库密文重加密和备份。中继开发机只保留已收紧 ACL 的隧道端点配置,不保存数据库密码。 详细 SOP 见 `docs/开发与部署SOP.md`。 diff --git a/app/Http/Controllers/SystemSettings/Databases.php b/app/Http/Controllers/SystemSettings/Databases.php index 944a5a36..71c98590 100644 --- a/app/Http/Controllers/SystemSettings/Databases.php +++ b/app/Http/Controllers/SystemSettings/Databases.php @@ -342,7 +342,7 @@ class Databases extends Controller if (! is_string($password) || $password === '') { throw new DecryptException( - '本机无法解密共享密码,请在本机私有 runtime/.env 中配置该来源的数据库密码。' + '无法解锁共享云数据库密码,请确认系统数据库密码与保存共享配置时一致,并由管理员迁移旧配置。' ); } diff --git a/app/Models/Settings/DatabaseConnection.php b/app/Models/Settings/DatabaseConnection.php index 6bee08d3..b29d98a9 100644 --- a/app/Models/Settings/DatabaseConnection.php +++ b/app/Models/Settings/DatabaseConnection.php @@ -2,7 +2,9 @@ namespace App\Models\Settings; +use App\Utilities\UpstreamCredentialCipher; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\Crypt; class DatabaseConnection extends Model { @@ -28,10 +30,39 @@ class DatabaseConnection extends Model ]; protected $casts = [ - 'password' => 'encrypted', 'allowed_scopes' => 'array', 'enabled' => 'boolean', ]; - protected $hidden = ['password']; + protected $hidden = ['password', 'shared_password']; + + public function getPasswordAttribute(?string $value): ?string + { + return $value === null ? null : Crypt::decryptString($value); + } + + public function setPasswordAttribute(?string $value): void + { + if ($value === null || $value === '') { + $this->attributes['password'] = null; + $this->attributes['shared_password'] = null; + + return; + } + + // 同时保留 APP_KEY 旧密文,确保旧版本和紧急回滚仍可使用。 + $this->attributes['password'] = Crypt::encryptString($value); + $this->attributes['shared_password'] = app(UpstreamCredentialCipher::class)->encrypt($value); + } + + public function resolvedPassword(): ?string + { + $shared = (string) ($this->attributes['shared_password'] ?? ''); + + if ($shared !== '') { + return app(UpstreamCredentialCipher::class)->decrypt($shared); + } + + return $this->password; + } } diff --git a/app/Utilities/SystemDatabaseConfiguration.php b/app/Utilities/SystemDatabaseConfiguration.php index fa5661ed..9cc61205 100644 --- a/app/Utilities/SystemDatabaseConfiguration.php +++ b/app/Utilities/SystemDatabaseConfiguration.php @@ -149,6 +149,10 @@ class SystemDatabaseConfiguration 'DB_PREFIX' => $prefix, 'MYSQL_ATTR_SSL_CIPHER' => ! empty($settings['tls_required']) ? 'DEFAULT' : '', 'MYSQL_ATTR_SSL_VERIFY_SERVER_CERT' => 'false', + // 系统数据库保存后统一读取共享云数据库配置,不再要求开发机 + // 另外维护完整的 DB__* 连接参数。 + 'UPSTREAM_STORED_CONFIG_ENABLED' => 'true', + 'UPSTREAM_LOCAL_CONFIG_ENABLED' => 'false', ]); if (file_put_contents($this->environmentPath, $updated, LOCK_EX) === false) { diff --git a/app/Utilities/UpstreamCredentialCipher.php b/app/Utilities/UpstreamCredentialCipher.php new file mode 100644 index 00000000..5324fcf1 --- /dev/null +++ b/app/Utilities/UpstreamCredentialCipher.php @@ -0,0 +1,120 @@ +deriveKey($salt), + OPENSSL_RAW_DATA, + $iv, + $tag, + self::AAD, + 16 + ); + + if (! is_string($encrypted) || strlen($tag) !== 16) { + throw new RuntimeException('云数据库密码加密失败。'); + } + + $payload = json_encode([ + 'salt' => base64_encode($salt), + 'iv' => base64_encode($iv), + 'tag' => base64_encode($tag), + 'value' => base64_encode($encrypted), + ], JSON_THROW_ON_ERROR); + + return self::PREFIX . base64_encode($payload); + } + + public function decrypt(string $payload): string + { + if (! str_starts_with($payload, self::PREFIX)) { + throw new DecryptException('共享云数据库密码格式不受支持。'); + } + + try { + $encoded = substr($payload, strlen(self::PREFIX)); + $json = base64_decode($encoded, true); + $data = is_string($json) + ? json_decode($json, true, 16, JSON_THROW_ON_ERROR) + : null; + + if (! is_array($data)) { + throw new RuntimeException('invalid payload'); + } + + $salt = base64_decode((string) ($data['salt'] ?? ''), true); + $iv = base64_decode((string) ($data['iv'] ?? ''), true); + $tag = base64_decode((string) ($data['tag'] ?? ''), true); + $encrypted = base64_decode((string) ($data['value'] ?? ''), true); + + if ( + ! is_string($salt) || strlen($salt) !== 16 + || ! is_string($iv) || strlen($iv) !== 12 + || ! is_string($tag) || strlen($tag) !== 16 + || ! is_string($encrypted) || $encrypted === '' + ) { + throw new RuntimeException('invalid payload'); + } + + $decrypted = openssl_decrypt( + $encrypted, + self::CIPHER, + $this->deriveKey($salt), + OPENSSL_RAW_DATA, + $iv, + $tag, + self::AAD + ); + + if (! is_string($decrypted) || $decrypted === '') { + throw new RuntimeException('invalid credential'); + } + + return $decrypted; + } catch (DecryptException $exception) { + throw $exception; + } catch (\Throwable) { + throw new DecryptException('系统数据库凭据无法解锁共享云数据库密码。'); + } + } + + private function deriveKey(string $salt): string + { + $connection = (string) config('database.default', 'mysql'); + $password = (string) config('database.connections.' . $connection . '.password', ''); + + if ($password === '') { + throw new RuntimeException('系统数据库密码不可用,无法处理共享云数据库密码。'); + } + + return hash_hkdf('sha256', $password, 32, self::AAD, $salt); + } +} diff --git a/config/upstreams.php b/config/upstreams.php index 9a1ad57f..d71e2d7f 100644 --- a/config/upstreams.php +++ b/config/upstreams.php @@ -7,7 +7,8 @@ return [ |-------------------------------------------------------------------------- | | 每个来源使用独立配置、缓存命名空间、查询预算和熔断状态。新增来源时 - | 只在代码中登记非敏感标识;生产凭据可在明确开启后加密保存到系统库。 + | 只在代码中登记非敏感标识;生产凭据可在明确开启后加密保存到系统库, + | 并由同一套系统数据库登录凭据跨受信任开发环境解锁。 | */ 'runtime' => [ diff --git a/database/migrations/2026_07_23_000000_add_shared_password_to_system_database_connections.php b/database/migrations/2026_07_23_000000_add_shared_password_to_system_database_connections.php new file mode 100644 index 00000000..89949faf --- /dev/null +++ b/database/migrations/2026_07_23_000000_add_shared_password_to_system_database_connections.php @@ -0,0 +1,26 @@ +text('shared_password')->nullable()->after('password'); + }); + } + } + + public function down(): void + { + if (Schema::hasColumn('system_database_connections', 'shared_password')) { + Schema::table('system_database_connections', function (Blueprint $table) { + $table->dropColumn('shared_password'); + }); + } + } +}; diff --git a/docs/开发与部署SOP.md b/docs/开发与部署SOP.md index c5a3b64e..cc74432b 100644 --- a/docs/开发与部署SOP.md +++ b/docs/开发与部署SOP.md @@ -139,7 +139,7 @@ git pull --ff-only ### 8.1 统一接入边界 -本系统按 3 个以上上游数据库设计,但不会把多个数据库直接挂到页面上。`config/upstreams.php` 只登记不敏感的来源标识和配置文件名;每个来源再分别提供连接配置、固定查询适配器、允许的数据范围、快照、限流、熔断和刷新器。系统数据库的启动连接只存在于服务器私有 `runtime/.env`;显式开启 `UPSTREAM_STORED_CONFIG_ENABLED` 后,云数据库端点及账号保存在系统库中,密码使用当前 `APP_KEY` 加密。共享系统库的开发环境不复制生产 `APP_KEY`;连接名称、地址和数据库名从系统库读取,密码由各开发机私有环境变量覆盖。 +本系统按 3 个以上上游数据库设计,但不会把多个数据库直接挂到页面上。`config/upstreams.php` 只登记不敏感的来源标识和配置文件名;每个来源再分别提供连接配置、固定查询适配器、允许的数据范围、快照、限流、熔断和刷新器。系统数据库的启动连接只存在于服务器私有 `runtime/.env`;显式开启 `UPSTREAM_STORED_CONFIG_ENABLED` 后,云数据库端点及账号保存在系统库中。密码同时保留旧版 `APP_KEY` 密文用于紧急回滚,并使用系统数据库登录密码派生的密钥写入 `shared_password`,因此受信任开发机填写同一套系统数据库参数后即可读取共享云数据库配置,不需要复制生产 `APP_KEY` 或再次填写云数据库密码。 固定调用链如下: @@ -220,7 +220,9 @@ JIANSUN_SLOW_QUERY_MS=5000 JIANSUN_SLOW_QUERY_COOLDOWN=300 ``` -开发机需要直接使用数据库账号密码测试时,在本机私有 `runtime/.env` 设置 `UPSTREAM_LOCAL_CONFIG_ENABLED=true`,并完整填写 `DB_JIANSUN_*` 与 `DB_OPERATIONS_REPLICA_*`。开启后,该开发环境的上游连接、开关和允许范围全部使用本机配置,不读取共享密码;系统数据库仍只共享用户和系统设置。每位开发者不得在网页中覆盖共享密码,生产 `APP_KEY` 和各开发机私有配置不得进入 Git、聊天、日志或截图。 +开发机完成系统数据库配置后,系统自动设置 `UPSTREAM_STORED_CONFIG_ENABLED=true` 和 `UPSTREAM_LOCAL_CONFIG_ENABLED=false`,连接名称、地址、数据库名、账号、允许范围和共享密码均从系统数据库读取。不同开发机可以使用不同 `APP_KEY`,不得复制生产 `APP_KEY`。 + +只有隔离排错或尚未迁移的旧环境需要直接使用本机数据库参数时,才在私有 `runtime/.env` 设置 `UPSTREAM_LOCAL_CONFIG_ENABLED=true`,并完整填写 `DB_JIANSUN_*` 与 `DB_OPERATIONS_REPLICA_*`。开启后,该开发环境完全忽略共享云数据库配置。系统数据库密码轮换前必须先备份 `system_database_connections` 并使用新旧密码完成 `shared_password` 重加密;任何密码、密钥和私有配置不得进入 Git、聊天、日志或截图。 `JIANSUN_ALLOWED_SCOPES` 支持逗号分隔的精确范围和 `prefix.*`。首次上线先开放完成探测的小表范围,例如 `invoice-deductions.*`;约 280 万行核心表对应范围必须单独验证,未验证前不得填写 `*`。 diff --git a/modules/EcommerceFinance/Services/UpstreamStoredConfiguration.php b/modules/EcommerceFinance/Services/UpstreamStoredConfiguration.php index aa2b71ad..a3accbeb 100644 --- a/modules/EcommerceFinance/Services/UpstreamStoredConfiguration.php +++ b/modules/EcommerceFinance/Services/UpstreamStoredConfiguration.php @@ -7,7 +7,7 @@ use Illuminate\Contracts\Encryption\DecryptException; use Illuminate\Support\Facades\Schema; /** - * 从系统数据库读取经 Laravel APP_KEY 加密保存的上游连接配置。 + * 从系统数据库读取可由同一套系统数据库凭据解锁的上游连接配置。 * * 本地、开发和测试环境保持可用;生产环境必须通过私有环境变量显式开启。 */ @@ -78,10 +78,9 @@ class UpstreamStoredConfiguration private function password(DatabaseConnection $connection, mixed $localPassword): mixed { try { - return $connection->password; + return $connection->resolvedPassword(); } catch (DecryptException) { - // 共享系统库中的密码可能由另一环境的 APP_KEY 加密。 - // 开发机只使用自身私有环境变量,不覆盖共享记录。 + // 旧记录或尚未迁移的环境仍允许使用本机私有密码。 return $localPassword; } } diff --git a/tests/Feature/SystemSettings/UpstreamDatabasesTest.php b/tests/Feature/SystemSettings/UpstreamDatabasesTest.php index 20096316..fa11c9e8 100644 --- a/tests/Feature/SystemSettings/UpstreamDatabasesTest.php +++ b/tests/Feature/SystemSettings/UpstreamDatabasesTest.php @@ -5,6 +5,7 @@ namespace Tests\Feature\SystemSettings; use App\Models\Auth\Permission; use App\Models\Auth\Role; use App\Models\Settings\DatabaseConnection; +use App\Utilities\UpstreamCredentialCipher; use Illuminate\Encryption\Encrypter; use Illuminate\Support\Facades\DB; use Modules\EcommerceFinance\Services\UpstreamSourceRegistry; @@ -20,6 +21,9 @@ class UpstreamDatabasesTest extends FeatureTestCase $this->user->attachPermission( Permission::where('name', 'read-admin-panel')->firstOrFail() ); + + $connection = (string) config('database.default'); + config(["database.connections.$connection.password" => 'shared-system-database-secret']); } public function test_super_admin_can_add_multiple_encrypted_database_configurations(): void @@ -46,6 +50,15 @@ class UpstreamDatabasesTest extends FeatureTestCase 'first-secret', DB::table('system_database_connections')->where('id', $connection->id)->value('password') ); + $sharedPassword = DB::table('system_database_connections') + ->where('id', $connection->id) + ->value('shared_password'); + $this->assertIsString($sharedPassword); + $this->assertNotSame('first-secret', $sharedPassword); + $this->assertSame( + 'first-secret', + app(UpstreamCredentialCipher::class)->decrypt($sharedPassword) + ); $sources = app(UpstreamSourceRegistry::class); $this->assertSame('127.0.0.1', $sources->value('jiansun', 'connection.host')); @@ -113,6 +126,29 @@ class UpstreamDatabasesTest extends FeatureTestCase } } + public function test_shared_password_is_available_with_a_different_app_key(): void + { + $connection = $this->connection('shared-secret'); + $connection->update(['source_key' => 'jiansun']); + $originalEncrypter = $this->app->make('encrypter'); + + try { + $this->app->instance( + 'encrypter', + new Encrypter(str_repeat('x', 32), (string) config('app.cipher')) + ); + config(['upstreams.local_configuration.enabled' => false]); + $sources = new UpstreamSourceRegistry(new UpstreamStoredConfiguration()); + + $this->assertSame( + 'shared-secret', + $sources->value('jiansun', 'connection.password') + ); + } finally { + $this->app->instance('encrypter', $originalEncrypter); + } + } + public function test_global_wildcard_scope_is_rejected(): void { $this->withExceptionHandling(); diff --git a/tests/Unit/Utilities/UpstreamCredentialCipherTest.php b/tests/Unit/Utilities/UpstreamCredentialCipherTest.php new file mode 100644 index 00000000..ba4d075b --- /dev/null +++ b/tests/Unit/Utilities/UpstreamCredentialCipherTest.php @@ -0,0 +1,56 @@ + 'shared-system-database-secret']); + } + + public function test_shared_credential_does_not_depend_on_app_key(): void + { + $cipher = app(UpstreamCredentialCipher::class); + $encrypted = $cipher->encrypt('cloud-readonly-secret'); + $originalEncrypter = app('encrypter'); + + try { + app()->instance( + 'encrypter', + new Encrypter(str_repeat('x', 32), (string) config('app.cipher')) + ); + + $this->assertSame('cloud-readonly-secret', $cipher->decrypt($encrypted)); + } finally { + app()->instance('encrypter', $originalEncrypter); + } + } + + public function test_different_system_database_password_cannot_decrypt_credential(): void + { + $cipher = app(UpstreamCredentialCipher::class); + $encrypted = $cipher->encrypt('cloud-readonly-secret'); + $connection = (string) config('database.default'); + config(["database.connections.$connection.password" => 'different-system-database-secret']); + + $this->expectException(DecryptException::class); + $cipher->decrypt($encrypted); + } + + public function test_ciphertext_does_not_contain_plaintext(): void + { + $encrypted = app(UpstreamCredentialCipher::class)->encrypt('cloud-readonly-secret'); + + $this->assertStringStartsWith('caiwu-system-db-v1:', $encrypted); + $this->assertStringNotContainsString('cloud-readonly-secret', $encrypted); + } +}