diff --git a/AGENTS.md b/AGENTS.md index 47d7ed97..4fbf246d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`、本机测试库名或历史截图中的临时名称写回共享记录,也不得交换两个来源的账号或数据库名。 diff --git a/LOCAL_DEPLOY.md b/LOCAL_DEPLOY.md index 89e10753..63581ffc 100644 --- a/LOCAL_DEPLOY.md +++ b/LOCAL_DEPLOY.md @@ -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. 返回数据库管理页面,确认状态为“共享 · 已连接”,并确认两个云数据库已经自动出现。 diff --git a/app/Console/Commands/ApplicationReady.php b/app/Console/Commands/ApplicationReady.php index 689cdb8c..8f6e5d2a 100644 --- a/app/Console/Commands/ApplicationReady.php +++ b/app/Console/Commands/ApplicationReady.php @@ -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; } diff --git a/app/Http/Controllers/SystemSettings/Databases.php b/app/Http/Controllers/SystemSettings/Databases.php index cdcf7e78..7ca1b451 100644 --- a/app/Http/Controllers/SystemSettings/Databases.php +++ b/app/Http/Controllers/SystemSettings/Databases.php @@ -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'); } diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index be763dbc..4b1df040 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -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, ]; /** diff --git a/app/Http/Middleware/RoleAwarePermission.php b/app/Http/Middleware/RoleAwarePermission.php new file mode 100644 index 00000000..901aed4a --- /dev/null +++ b/app/Http/Middleware/RoleAwarePermission.php @@ -0,0 +1,19 @@ +user())) { + return $next($request); + } + + return parent::handle($request, $next, $permissions, $team, $options); + } +} diff --git a/app/Utilities/SystemDatabaseConfiguration.php b/app/Utilities/SystemDatabaseConfiguration.php index 22a53227..867b9567 100644 --- a/app/Utilities/SystemDatabaseConfiguration.php +++ b/app/Utilities/SystemDatabaseConfiguration.php @@ -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 diff --git a/docs/开发与部署SOP.md b/docs/开发与部署SOP.md index 9d4909c1..bd84c1ba 100644 --- a/docs/开发与部署SOP.md +++ b/docs/开发与部署SOP.md @@ -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。 diff --git a/tests/Feature/Auth/AdminPermissionBypassTest.php b/tests/Feature/Auth/AdminPermissionBypassTest.php new file mode 100644 index 00000000..aaa555c6 --- /dev/null +++ b/tests/Feature/Auth/AdminPermissionBypassTest.php @@ -0,0 +1,30 @@ +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()); + } +} diff --git a/tests/Feature/Installation/ApplicationReadyTest.php b/tests/Feature/Installation/ApplicationReadyTest.php index fbcac081..cbc18137 100644 --- a/tests/Feature/Installation/ApplicationReadyTest.php +++ b/tests/Feature/Installation/ApplicationReadyTest.php @@ -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();