修复:移除系统数据库接入权限阻塞
This commit is contained in:
parent
49e507b7e2
commit
d9e5b5246c
@ -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`、本机测试库名或历史截图中的临时名称写回共享记录,也不得交换两个来源的账号或数据库名。
|
||||
|
||||
@ -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. 返回数据库管理页面,确认状态为“共享 · 已连接”,并确认两个云数据库已经自动出现。
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
@ -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,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
19
app/Http/Middleware/RoleAwarePermission.php
Normal file
19
app/Http/Middleware/RoleAwarePermission.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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。
|
||||
|
||||
|
||||
30
tests/Feature/Auth/AdminPermissionBypassTest.php
Normal file
30
tests/Feature/Auth/AdminPermissionBypassTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user