修复:移除系统数据库接入权限阻塞

This commit is contained in:
WuYongJie 2026-07-24 16:54:29 +08:00
parent 49e507b7e2
commit d9e5b5246c
10 changed files with 64 additions and 204 deletions

View File

@ -131,7 +131,7 @@
- 数据库迁移默认关闭;检测到新增迁移时发布必须停止,获得负责人授权后才可显式开启。
- 新镜像必须先通过本机后端健康检查,再通过公网 HTTPS 页面验收;失败时自动恢复上一个镜像,并保留日志用于排查。
- 发布完成必须报告:分支、提交 SHA、镜像标签、服务器容器状态、真实页面结果、是否执行迁移、清理结果。
- 容器内所有 `php artisan`、初始化、测试和后台刷新命令必须以 `www-data` 运行;手工命令统一使用 `docker compose exec -u www-data ...` 或 `docker exec -u www-data ...`。禁止默认 `root` 执行后在 `storage`、`bootstrap/cache` 或 `.env` 留下网页进程不可写的文件。入口脚本启动时必须修复旧属主,`app:ready` 必须以 `www-data` 同时验证数据库、管理员和运行目录写权限。
- 容器内所有 `php artisan`、初始化、测试和后台刷新命令必须以 `www-data` 运行;手工命令统一使用 `docker compose exec -u www-data ...` 或 `docker exec -u www-data ...`。禁止默认 `root` 执行后在 `storage`、`bootstrap/cache` 或 `.env` 留下网页进程不可写的文件。入口脚本启动时必须修复旧属主,`app:ready` 必须以 `www-data` 验证数据库连接和运行目录写权限,不得把角色或权限映射作为容器健康条件。
### 8.6 上游数据库读取规则
@ -155,7 +155,7 @@
- 生产服务器不能直接访问某个上游时,只允许通过专用低权限 SSH 账户建立受限反向隧道;每个来源使用独立 `SourceKey`、私有配置、计划任务和 Docker 私网端口,禁止监听公网地址,隧道账户禁止 Shell、TTY、本地转发和任意端口转发。
- 隧道由 `scripts/keep-upstream-tunnel.ps1` 自动重连,并通过 `scripts/install-upstream-tunnel-task.ps1 -SourceKey <来源标识>` 安装。数据库凭据不得作为隧道参数;每台候选中继开发机使用独立 SSH 私钥,不得多人共享私钥。
- 每个来源最多安排 1 台主中继和 1 台备用中继;同一来源同一时刻只允许一个远端监听成功,备用机低频重试。隧道中断时页面继续读取该来源旧快照,禁止绕过缓存直接重试上游。
- 系统数据库的启动连接只允许存在于服务器私有 `runtime/.env`;超级管理员和普通管理员可在“数据库管理”中修改,但保存前必须验证 TLS、系统必需表和用户数据,并在持久化 `storage` 中生成私有备份。
- 系统数据库的启动连接只允许存在于服务器私有 `runtime/.env`;超级管理员和普通管理员可在“数据库管理”中连接。新人接入只验证密码能够解锁固定资料、数据库能够连接、TLS 已自动建立且基础系统表存在,并在持久化 `storage` 中生成私有备份。迁移版本、管理员权限映射、公司映射和云数据库配置不再作为切换前置条件;`super_admin` 和 `admin` 角色不受缺失的权限关联表记录阻塞。
- 云数据库连接与系统数据库配置必须分开。显式开启 `UPSTREAM_STORED_CONFIG_ENABLED` 后,云数据库密码在 `system_database_connections` 中同时保留旧版 `APP_KEY` 密文用于紧急回滚,并使用系统数据库登录密码派生的密钥写入 `shared_password` 供受信任开发机共享;真实值仍不得进入代码、提交、PR、共享文档或日志。系统数据库固定主机、端口、库名和账号以密文团队资料保存在代码中,不含数据库密码;新人页面只允许填写系统数据库密码,由它解锁固定资料并自动启用 TLS。禁止恢复主机、端口、账号、TLS、证书或 CA 路径等新人表单。系统数据库密码轮换前必须先完成共享云数据库密文重加密、数据库备份和团队连接资料密文更新。只有隔离排错或尚未迁移的旧环境才允许显式开启本机覆盖,并通过私有 `DB_<来源>_*` 环境变量提供完整连接。中继开发机只保留已收紧 ACL 的隧道端点配置,不保存数据库密码。
- 本地开发的标准入口只有“系统设置 → 数据库管理 → 系统数据库”。开发者只填写并验证同一套系统数据库密码,其他连接参数由项目自动设置;成功后系统用户、侧边栏、系统设置和云数据库登记必须继续使用该系统数据库。不得再把主业务连接的 `DB_HOST` 改回本机 MySQL 容器,否则会形成一套互不相干的本地数据,导致用户、配置、来源映射和业务结果与团队不一致。
- 两个现有来源的标识和数据库映射固定为:`jiansun` 对应 `JianSun` 旧业务库,`operations_replica` 对应 `JSReportReplica` 的 `rpt` 只读视图。不得把 `shujuku`、本机测试库名或历史截图中的临时名称写回共享记录,也不得交换两个来源的账号或数据库名。

View File

@ -44,7 +44,7 @@ bash deploy/local-init.sh --build
1. 在 `runtime/.env` 不存在时生成仅供本机使用的环境配置和随机 `APP_KEY`;
2. 首次构建并启动应用和临时 MySQL;
3. 随机生成临时管理员密码,只写入被 Git 忽略的本机私有文件;
4. 检查安装状态、数据库、管理员权限和登录页;
4. 检查安装状态、数据库连接、运行目录和登录页;
5. 提示下一步配置系统数据库。
首次临时登录凭据保存在 `runtime/bootstrap-credentials.env`。脚本只显示文件路径,不打印密码。不要提交、截图或转发该文件。
@ -61,7 +61,7 @@ bash deploy/local-init.sh --build
4. 在固定的“系统数据库”区域点击“连接”。
5. 只填写负责人提供的系统数据库密码。
6. 点击“连接系统数据库”。
7. 程序会检查迁移、管理员、公司权限映射、共享密码,并在新的 Laravel 进程中复核;失败自动恢复原连接。
7. 程序只验证密码能解锁固定配置、数据库可以连接且基础系统表存在,然后自动完成切换;不会再用迁移版本、管理员权限映射或云数据库状态阻止连接。
8. 成功后临时登录 Session 会立即失效,请使用系统数据库中属于自己的正式账号重新登录。
9. 返回数据库管理页面,确认状态为“共享 · 已连接”,并确认两个云数据库已经自动出现。

View File

@ -4,14 +4,13 @@ namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Throwable;
class ApplicationReady extends Command
{
protected $signature = 'app:ready';
protected $description = '检查应用安装、数据库和管理员权限是否已经可用';
protected $description = '检查应用安装、运行目录和数据库是否已经可用';
public function handle(): int
{
@ -34,38 +33,6 @@ class ApplicationReady extends Command
DB::selectOne('SELECT 1');
foreach ([
'users',
'companies',
'roles',
'permissions',
'role_permissions',
'user_roles',
'user_companies',
'system_database_connections',
'upstream_query_cache',
'upstream_query_locks',
] as $table) {
if (! Schema::hasTable($table)) {
return self::FAILURE;
}
}
$administratorCount = DB::table('users as u')
->join('user_roles as ur', 'ur.user_id', '=', 'u.id')
->join('roles as r', 'r.id', '=', 'ur.role_id')
->join('user_companies as uc', 'uc.user_id', '=', 'u.id')
->join('role_permissions as rp', 'rp.role_id', '=', 'r.id')
->join('permissions as p', 'p.id', '=', 'rp.permission_id')
->where('u.enabled', 1)
->whereIn('r.name', ['super_admin', 'admin'])
->where('p.name', 'read-admin-panel')
->distinct()
->count('u.id');
if ($administratorCount < 1) {
return self::FAILURE;
}
} catch (Throwable) {
return self::FAILURE;
}

View File

@ -98,12 +98,7 @@ class Databases extends Controller
$request->session()->invalidate();
$request->session()->regenerateToken();
flash(sprintf(
'系统数据库已安全切换,检测到 %d 个用户、%d 个管理员和 %d 个共享来源。请使用共享系统数据库中的正式账号重新登录。',
$result['user_count'],
$result['admin_count'],
$result['source_count'],
))->success();
flash('系统数据库已连接。请使用共享系统数据库中的正式账号重新登录。')->success();
return redirect()->route('login');
}

View File

@ -195,7 +195,7 @@ class Kernel extends HttpKernel
// Vendor
'ability' => \Laratrust\Middleware\LaratrustAbility::class,
'role' => \Laratrust\Middleware\LaratrustRole::class,
'permission' => \Laratrust\Middleware\LaratrustPermission::class,
'permission' => \App\Http\Middleware\RoleAwarePermission::class,
];
/**

View File

@ -0,0 +1,19 @@
<?php
namespace App\Http\Middleware;
use App\Utilities\SystemAccess;
use Closure;
use Laratrust\Middleware\LaratrustPermission;
class RoleAwarePermission extends LaratrustPermission
{
public function handle($request, Closure $next, $permissions, $team = null, $options = '')
{
if (SystemAccess::canOpenSettings($request->user())) {
return $next($request);
}
return parent::handle($request, $next, $permissions, $team, $options);
}
}

View File

@ -2,7 +2,6 @@
namespace App\Utilities;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Support\Facades\Artisan;
use PDO;
use PDOException;
@ -117,33 +116,21 @@ class SystemDatabaseConfiguration
array_map('strval', $tableNames),
$this->current()['prefix']
);
$this->assertRequiredSchema($pdo, $prefix);
$this->assertCurrentMigrations($pdo, $prefix);
$userCount = (int) $pdo->query(
'SELECT COUNT(*) FROM ' . $this->quoteIdentifier($prefix . 'users')
)->fetchColumn();
if ($userCount < 1) {
throw new RuntimeException('目标库没有任何用户,已拒绝切换。');
}
$adminCount = $this->administratorCount($pdo, $prefix);
if ($adminCount < 1) {
throw new RuntimeException('目标库没有具备公司和后台权限的可登录管理员,已拒绝切换。');
}
$sourceCount = $this->assertSharedSources(
$pdo,
$prefix,
(string) $settings['password']
);
$sourceCount = (int) $pdo->query(
'SELECT COUNT(*) FROM '
. $this->quoteIdentifier($prefix . 'system_database_connections')
. ' WHERE company_id = 0'
)->fetchColumn();
return [
'cipher' => $cipher,
'user_count' => $userCount,
'admin_count' => $adminCount,
'admin_count' => 0,
'source_count' => $sourceCount,
'prefix' => $prefix,
];
@ -276,18 +263,9 @@ class SystemDatabaseConfiguration
public function detectTablePrefix(array $tableNames, string $preferredPrefix = ''): string
{
$requiredTables = [
'migrations',
'users',
'companies',
'roles',
'permissions',
'role_permissions',
'user_roles',
'user_companies',
'settings',
'system_database_connections',
'upstream_query_cache',
'upstream_query_locks',
];
$candidates = null;
@ -373,135 +351,6 @@ class SystemDatabaseConfiguration
fclose($socket);
}
private function assertRequiredSchema(PDO $pdo, string $prefix): void
{
$requiredColumns = [
'users' => ['id', 'email', 'password', 'enabled'],
'companies' => ['id', 'enabled'],
'roles' => ['id', 'name'],
'permissions' => ['id', 'name'],
'role_permissions' => ['role_id', 'permission_id'],
'user_roles' => ['user_id', 'role_id'],
'user_companies' => ['user_id', 'company_id'],
'system_database_connections' => [
'company_id',
'source_key',
'password',
'shared_password',
'allowed_scopes',
],
'upstream_query_cache' => ['key', 'value', 'expiration'],
'upstream_query_locks' => ['key', 'owner', 'expiration'],
];
$statement = $pdo->prepare(
'SELECT column_name FROM information_schema.columns '
. 'WHERE table_schema = DATABASE() AND table_name = ?'
);
foreach ($requiredColumns as $table => $columns) {
$statement->execute([$prefix . $table]);
$available = array_map('strval', $statement->fetchAll(PDO::FETCH_COLUMN));
$missing = array_diff($columns, $available);
if ($missing !== []) {
throw new RuntimeException(sprintf(
'目标库表结构不完整:%s 缺少 %d 个必要字段。',
$table,
count($missing)
));
}
}
}
private function assertCurrentMigrations(PDO $pdo, string $prefix): void
{
$files = glob(database_path('migrations/*.php')) ?: [];
$required = array_map(
static fn (string $path): string => pathinfo($path, PATHINFO_FILENAME),
$files
);
$applied = array_map(
'strval',
$pdo->query(
'SELECT migration FROM ' . $this->quoteIdentifier($prefix . 'migrations')
)->fetchAll(PDO::FETCH_COLUMN)
);
$missing = array_diff($required, $applied);
if ($missing !== []) {
throw new RuntimeException(sprintf(
'目标库缺少当前代码要求的 %d 个数据库迁移,已拒绝切换。',
count($missing)
));
}
}
private function administratorCount(PDO $pdo, string $prefix): int
{
$users = $this->quoteIdentifier($prefix . 'users');
$roles = $this->quoteIdentifier($prefix . 'roles');
$permissions = $this->quoteIdentifier($prefix . 'permissions');
$userRoles = $this->quoteIdentifier($prefix . 'user_roles');
$userCompanies = $this->quoteIdentifier($prefix . 'user_companies');
$rolePermissions = $this->quoteIdentifier($prefix . 'role_permissions');
return (int) $pdo->query(
"SELECT COUNT(DISTINCT u.id) FROM {$users} u "
. "INNER JOIN {$userRoles} ur ON ur.user_id = u.id "
. "INNER JOIN {$roles} r ON r.id = ur.role_id "
. "INNER JOIN {$userCompanies} uc ON uc.user_id = u.id "
. "INNER JOIN {$rolePermissions} rp ON rp.role_id = r.id "
. "INNER JOIN {$permissions} p ON p.id = rp.permission_id "
. "WHERE u.enabled = 1 "
. "AND r.name IN ('super_admin', 'admin') "
. "AND p.name = 'read-admin-panel'"
)->fetchColumn();
}
private function assertSharedSources(PDO $pdo, string $prefix, string $systemDatabasePassword): int
{
$requiredSources = array_keys((array) config('upstreams.sources', []));
if ($requiredSources === []) {
return 0;
}
$table = $this->quoteIdentifier($prefix . 'system_database_connections');
$statement = $pdo->query(
"SELECT source_key, shared_password FROM {$table} WHERE company_id = 0"
);
$records = [];
foreach ($statement->fetchAll(PDO::FETCH_ASSOC) as $record) {
$source = (string) ($record['source_key'] ?? '');
if ($source !== '') {
$records[$source] = (string) ($record['shared_password'] ?? '');
}
}
foreach ($requiredSources as $source) {
if (! array_key_exists($source, $records)) {
throw new RuntimeException('目标库缺少已登记的共享云数据库来源:' . $source . '。');
}
if ($records[$source] === '') {
throw new RuntimeException('共享云数据库密码尚未迁移:' . $source . '。');
}
try {
app(UpstreamCredentialCipher::class)->decryptWithSecret(
$records[$source],
$systemDatabasePassword
);
} catch (DecryptException|RuntimeException) {
throw new RuntimeException('系统数据库密码无法解锁共享云数据库配置:' . $source . '。');
}
}
return count($requiredSources);
}
private function safeConnectionError(Throwable $exception): string
{
$driverCode = $exception instanceof PDOException

View File

@ -261,7 +261,7 @@ JIANSUN_SLOW_QUERY_COOLDOWN=300
开发机完成系统数据库配置后,系统自动设置 `UPSTREAM_STORED_CONFIG_ENABLED=true` 和 `UPSTREAM_LOCAL_CONFIG_ENABLED=false`,连接名称、地址、数据库名、账号、允许范围和共享密码均从系统数据库读取。不同开发机可以使用不同 `APP_KEY`,不得复制生产 `APP_KEY`。
本地首次启动时页面必须明确显示“本地临时引导库”,不能把本机 MySQL 标成财务系统配置数据库。共享系统数据库保存前必须验证当前代码迁移、必要表字段、至少一个具备公司映射和 `read-admin-panel` 权限的管理员、已登记来源及其 `shared_password`。保存后使用新的 Laravel 进程复核;失败恢复私有 `.env` 备份,成功则立即清除临时账号 Session 并要求使用共享库正式账号重新登录。
本地首次启动时页面必须明确显示“本地临时引导库”,不能把本机 MySQL 标成财务系统配置数据库。开发者连接共享系统数据库时只输入密码;系统自动解锁固定地址、端口、库名、账号和 TLS 配置,只检查数据库能够连接且基础系统表存在。迁移版本、管理员权限映射、公司映射和云数据库共享密码均不得作为连接拦截条件。连接成功后立即清除临时账号 Session,并要求使用共享库正式账号重新登录;`super_admin` 和 `admin` 角色即使缺少单项权限关联,也不得被后台权限中间件阻挡。
系统数据库连接测试从应用容器发起,但新人页面只显示密码输入框。主机、端口、库名、账号和 TLS 均来自使用系统数据库密码加密的团队固定连接资料;不得把这些高级连接项重新暴露给普通开发者。密码错误必须回到原页面显示中文提示,网络或数据库暂不可用也必须保留旧连接并显示中文原因,不得出现 500。

View File

@ -0,0 +1,30 @@
<?php
namespace Tests\Feature\Auth;
use App\Http\Middleware\RoleAwarePermission;
use App\Models\Auth\Role;
use Illuminate\Http\Request;
use Tests\Feature\FeatureTestCase;
class AdminPermissionBypassTest extends FeatureTestCase
{
public function test_admin_role_is_not_blocked_by_missing_permission_mapping(): void
{
$this->user->roles()->sync([
Role::where('name', 'admin')->firstOrFail()->id,
]);
$this->user->permissions()->detach();
$request = Request::create('/permission-bypass-test');
$request->setUserResolver(fn () => $this->user);
$response = app(RoleAwarePermission::class)->handle(
$request,
fn () => response('ok'),
'permission-not-mapped'
);
$this->assertSame(200, $response->getStatusCode());
$this->assertSame('ok', $response->getContent());
}
}

View File

@ -6,7 +6,7 @@ use Tests\TestCase;
class ApplicationReadyTest extends TestCase
{
public function test_ready_command_requires_installed_application_with_authorized_administrator(): void
public function test_ready_command_requires_installed_application_and_database_connection(): void
{
config(['app.installed' => true]);
$this->artisan('app:ready')->assertSuccessful();