From dd7bb7a9eeca9aff821877870434c3e764da0e32 Mon Sep 17 00:00:00 2001 From: ForgQi <34411314+ForgQi@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:44:35 +0800 Subject: [PATCH] Refine auth policy and migration compatibility Centralize authorization into a policy layer so route, field, and user-permission checks all use the same decision point. The frontend now revalidates /v1/me after 403s or dropped connections, and the users page fetches the real role/permission catalog from the backend instead of hardcoding role descriptions. This also tightens field-level protection for sensitive config/template data and avoids exposing user-management controls when auth is off. The backend and migrations are updated to handle LF/CRLF checkout differences in SQL migration checksums, with compatibility reconciliation for older databases and safeguards to prevent silent drift of previously shipped migrations. --- .gitattributes | 4 + .gitignore | 1 - app/(app)/layout.tsx | 14 +- app/(app)/logviewer/page.tsx | 5 + app/(app)/users/page.tsx | 63 +++-- app/lib/api-streamer.ts | 19 +- app/lib/danmaku-feed.ts | 7 +- app/lib/use-live-rates.ts | 9 +- app/lib/use-me.ts | 36 ++- app/ui/LivePreview.tsx | 4 +- crates/biliup-cli/build.rs | 29 +++ crates/biliup-cli/src/server/api/access.rs | 50 ++-- crates/biliup-cli/src/server/api/endpoints.rs | 124 +++++++-- crates/biliup-cli/src/server/api/web_users.rs | 89 ++++++- .../server/infrastructure/connection_pool.rs | 236 ++++++++++++++---- .../src/server/infrastructure/mod.rs | 2 + .../src/server/infrastructure/permissions.rs | 105 ++++---- .../src/server/infrastructure/policy.rs | 182 ++++++++++++++ .../src/server/infrastructure/users.rs | 17 +- 19 files changed, 798 insertions(+), 198 deletions(-) create mode 100644 .gitattributes create mode 100644 crates/biliup-cli/build.rs create mode 100644 crates/biliup-cli/src/server/infrastructure/policy.rs diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1da5c8d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# sqlx 对迁移文件的原始字节求 SHA-384 并记进 _sqlx_migrations。同一份 SQL 在 +# core.autocrlf=true 的 Windows(含 GitHub windows-latest 上的发布构建)会被检出成 CRLF, +# 编出来的二进制就内嵌另一套摘要。一律检出成 LF,各平台的构建才记同一套校验和。 +*.sql text eol=lf diff --git a/.gitignore b/.gitignore index bdaa5aa..c07b8b0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ .idea .vscode -.gitattributes *.pyc *.flv *.mp4 diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index a850177..9b9d127 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -131,9 +131,6 @@ const EXTRA_PAGE_PERMS: { prefix: string; perm: Permission }[] = [ { prefix: '/upload-manager/edit', perm: 'template.edit' }, ] -/** 每个角色都有的权限点:这些页面不必等 /v1/me 返回就能先渲染 */ -const UNIVERSAL_PERMS: Permission[] = ['streamer.view', 'preview.view', 'config.view', 'log.view', 'file.view'] - const matches = (pathname: string, href: string) => href === '/' ? pathname === '/' : pathname === href || pathname.startsWith(href + '/') @@ -142,7 +139,7 @@ function pagePermission(pathname: string): Permission | undefined { if (extra) return extra.perm for (const group of NAV_GROUPS) { for (const item of group.items) { - if (item.href !== '/' && matches(pathname, item.href)) return item.perm + if (matches(pathname, item.href)) return item.perm } } return undefined @@ -211,9 +208,7 @@ export default function Layout({ children }: { children: React.ReactNode }) { // 权限未加载完时整组菜单先不过滤(全部都是公开的静态页面,数据接口本身有后端拦截), // 避免每次进页面侧栏闪一下 - const { me, error: meError, can: canRaw } = useMe() - // 未开启 --auth 时没有登录用户,也就没有用户可管 - const can = (perm: Permission) => (perm === 'user.manage' ? !!me?.auth_enabled && canRaw(perm) : canRaw(perm)) + const { me, error: meError, can } = useMe() const visibleGroups = me ? NAV_GROUPS.map((group) => ({ ...group, items: group.items.filter((item) => can(item.perm)) })).filter( (group) => group.items.length > 0, @@ -221,8 +216,9 @@ export default function Layout({ children }: { children: React.ReactNode }) { : NAV_GROUPS.map((group) => ({ ...group, items: group.items.filter((item) => item.perm !== 'user.manage') })) const requiredPerm = pagePermission(pathname) const denied = !!me && !!requiredPerm && !can(requiredPerm) - // 需要特定权限的页面等权限加载完再渲染,免得没有权限的角色先发出一串注定 403 的请求 - const pending = !me && !meError && !!requiredPerm && !UNIVERSAL_PERMS.includes(requiredPerm) + // 需要权限的页面一律等权限加载完再渲染:前端不假设哪个权限点人人都有, + // 免得没有权限的人先发出一串注定 403 的请求。/v1/me 有缓存,只有首次进入会等 + const pending = !me && !meError && !!requiredPerm const [passwordOpen, setPasswordOpen] = useState(false) const logout = async () => { diff --git a/app/(app)/logviewer/page.tsx b/app/(app)/logviewer/page.tsx index 69977e5..e585b4f 100644 --- a/app/(app)/logviewer/page.tsx +++ b/app/(app)/logviewer/page.tsx @@ -5,6 +5,7 @@ import { Button, Spin, Typography, Tabs, TabPane, Toast } from '@douyinfe/semi-u import { IconCustomerSupport, IconRefresh, IconClear, IconSave } from '@douyinfe/semi-icons' import PageHeader from '../components/PageHeader' import dc from '@/app/ui/data-card.module.scss' +import { revalidateMe } from '@/app/lib/api-streamer' // 日志内容组件 interface LogContentProps { @@ -125,11 +126,15 @@ export default function LogViewer() { setIsLoading(false) } + // 切换标签、刷新、离开页面时是我们自己关的;其余的断开(含会话被收回后后端主动断开)要刷新权限点 + let closedByUs = false ws.onclose = () => { setIsConnected(false) + if (!closedByUs) revalidateMe() } return () => { + closedByUs = true ws.close() } }, [activeTab, connectSeq]) diff --git a/app/(app)/users/page.tsx b/app/(app)/users/page.tsx index 1324e59..3e74a68 100644 --- a/app/(app)/users/page.tsx +++ b/app/(app)/users/page.tsx @@ -17,9 +17,9 @@ import { IconPlusCircle, IconUserGroup } from '@douyinfe/semi-icons' import type { FormApi } from '@douyinfe/semi-ui/lib/es/form' import PageHeader from '../components/PageHeader' import dc from '@/app/ui/data-card.module.scss' -import { API_BASE, fetcher } from '@/app/lib/api-streamer' -import { ROLE_DESCRIPTIONS, ROLE_LABELS, useMe } from '@/app/lib/use-me' -import type { Role } from '@/app/lib/use-me' +import { API_BASE, fetcher, revalidateMe } from '@/app/lib/api-streamer' +import { PERMISSION_LABELS, ROLE_LABELS, useMe } from '@/app/lib/use-me' +import type { Permission, Role } from '@/app/lib/use-me' import { MIN_PASSWORD_LENGTH } from '@/app/ui/ChangePasswordModal' import { humDate } from '@/app/lib/utils' import { timeAgo } from '@/app/lib/use-dashboard' @@ -37,8 +37,15 @@ interface WebUser { last_login_at: number | null } +/** 角色目录:每个角色实际拥有的权限点,由后端授权决策点给出 */ +interface RoleInfo { + role: Role + permissions: Permission[] +} + const USERS_KEY = '/v1/web-users' -const ROLES: Role[] = ['admin', 'operator', 'viewer'] +const ROLES_KEY = '/v1/web-users/roles' +const ALL_PERMISSIONS = Object.keys(PERMISSION_LABELS) as Permission[] const ROLE_COLORS: Record = { admin: 'red', operator: 'blue', @@ -56,6 +63,8 @@ async function call(method: string, url: string, body?: unknown) { window.location.assign(`/login?next=${encodeURIComponent(window.location.pathname)}`) throw new Error('登录已失效') } + // 自己的用户管理权限被收回了:刷新权限点,页面随之换成无权限提示 + if (res.status === 403) revalidateMe() if (!res.ok) { const data = await res.json().catch(() => null) throw new Error(data?.message || `操作失败(HTTP ${res.status})`) @@ -72,13 +81,19 @@ const passwordRules = [ }, ] -function RoleOptions() { +/** 按后端给的权限点描述角色,前端不再手写「哪个角色能做什么」 */ +function describe(permissions: Permission[]) { + if (ALL_PERMISSIONS.every((p) => permissions.includes(p))) return '全部权限' + return permissions.map((p) => PERMISSION_LABELS[p] ?? p).join('、') +} + +function RoleOptions({ roles }: { roles: RoleInfo[] }) { return ( <> - {ROLES.map((role) => ( + {roles.map(({ role, permissions }) => ( {ROLE_LABELS[role]} - {ROLE_DESCRIPTIONS[role]} + {describe(permissions)} ))} @@ -95,10 +110,12 @@ type DialogValues = { username?: string; password?: string; role?: Role } function UserDialog({ dialog, + roles, onClose, onDone, }: { dialog: Exclude + roles: RoleInfo[] onClose: () => void onDone: () => void }) { @@ -191,7 +208,7 @@ function UserDialog({ )} {dialog.kind !== 'password' && ( - + )} @@ -205,10 +222,16 @@ export default function UsersPage() { // 六列表格放在侧栏旁边至少要 960 左右的视口;更窄时改成卡片,操作按钮不会被裁掉 const compact = useIsMobile(960) const { data: users, error, isLoading, mutate } = useSWR(USERS_KEY, fetcher) + const { data: roles, error: rolesError, isLoading: rolesLoading, mutate: mutateRoles } = useSWR( + ROLES_KEY, + fetcher, + { revalidateOnFocus: false }, + ) const [dialog, setDialog] = useState(null) const refresh = () => { mutate().catch(() => undefined) + if (rolesError) mutateRoles().catch(() => undefined) } const run = async (action: () => Promise, success: string) => { @@ -318,16 +341,19 @@ export default function UsersPage() { ] let body - if (isLoading) { + if (isLoading || rolesLoading) { body = (
) - } else if (error) { + } else if (error || rolesError || !roles) { body = (
- + @@ -375,7 +401,12 @@ export default function UsersPage() { title="用户管理" description="为其他人开设账号并分配角色。改动即时生效,至少保留一个启用中的超级管理员" actions={ - } @@ -393,17 +424,19 @@ export default function UsersPage() { )} {body}
- {ROLES.map((role) => ( + {roles?.map(({ role, permissions }) => (
{roleCell({ role } as WebUser)} - {ROLE_DESCRIPTIONS[role]} + {describe(permissions)}
))}
- {dialog && setDialog(null)} onDone={refresh} />} + {dialog && roles && ( + setDialog(null)} onDone={refresh} /> + )} ) } diff --git a/app/lib/api-streamer.ts b/app/lib/api-streamer.ts index f34fa43..24e738c 100644 --- a/app/lib/api-streamer.ts +++ b/app/lib/api-streamer.ts @@ -1,7 +1,19 @@ // Fetcher implementation. // The extra argument will be passed via the `arg` property of the 2nd parameter.// In the example below, `arg` will be `'my_token'` import { Toast } from '@douyinfe/semi-ui'; +import { mutate } from 'swr'; export const API_BASE = process.env.NEXT_PUBLIC_API_SERVER ?? ''; + +/** 当前用户与权限点(见 use-me.ts)。放在这里是为了让统一的响应处理能刷新它,又不形成循环引用。 */ +export const ME_KEY = '/v1/me'; + +/** + * 重新拉取当前用户与权限点。收到 403、长连接意外断开时调用: + * 会话已失效时 /v1/me 返回 401 → 跳登录页;角色被降级时页面按新的权限点收起。 + */ +export function revalidateMe() { + mutate(ME_KEY).catch(() => undefined); +} export async function sendRequest(url: string, { arg }: { arg: T }) { const res = await fetch(API_BASE + url, { method: 'POST', @@ -40,7 +52,8 @@ export async function put(url: string, { arg }: { arg: T }) { return res; } -async function handleResponse(res: Response) { +/** 统一的响应处理:401 跳登录页,403 提示并刷新权限点,其它失败抛出服务端信息。 */ +export async function handleResponse(res: Response) { // 如果未登录,统一跳转 if (res.status === 401) { // 可选:清理本地状态/缓存 @@ -53,8 +66,10 @@ async function handleResponse(res: Response) { throw new Error('Unauthorized'); } - // 已登录但角色没有这项权限:留在当前页,提示一次即可(同 id 的 Toast 不会叠加) + // 已登录但没有这项权限:留在当前页,提示一次即可(同 id 的 Toast 不会叠加)。 + // 多半是权限点在别处被改了,顺手刷新,让界面按新的权限收起 if (res.status === 403) { + revalidateMe(); const text = await res.text().catch(() => ''); let message = text.trim(); try { diff --git a/app/lib/danmaku-feed.ts b/app/lib/danmaku-feed.ts index 1759243..cedf08d 100644 --- a/app/lib/danmaku-feed.ts +++ b/app/lib/danmaku-feed.ts @@ -1,6 +1,6 @@ 'use client' import { useEffect, useMemo } from 'react' -import { API_BASE } from './api-streamer' +import { API_BASE, revalidateMe } from './api-streamer' /** SSE 里一条弹幕的形状,与后端 `DanmakuFrame` 一致 */ export interface DanmakuFrame { @@ -74,6 +74,11 @@ export class DanmakuFeed { this.listeners.get(frame.id)?.forEach((l) => l(frame)) } for (const kind of KINDS) source.addEventListener(kind, handle as EventListener) + // 断线后浏览器会自动重连;重连被拒(401 / 403,会话失效或失去预览权限)时不再重连、状态变为 CLOSED, + // 刷新一次权限点:失效就跳登录页,降级就收起页面 + source.onerror = () => { + if (source.readyState === EventSource.CLOSED) revalidateMe() + } this.source = source } } diff --git a/app/lib/use-live-rates.ts b/app/lib/use-live-rates.ts index a5db845..d71daf3 100644 --- a/app/lib/use-live-rates.ts +++ b/app/lib/use-live-rates.ts @@ -1,6 +1,6 @@ 'use client' import { useSyncExternalStore } from 'react' -import { API_BASE } from './api-streamer' +import { API_BASE, handleResponse, revalidateMe } from './api-streamer' /** * 录制中各房间的写盘速率采样:页面内唯一的一份数据源,供弹层折线图、卡片与监视器的 sparkline 共用。 @@ -211,7 +211,9 @@ function connectSocket() { schedule(0) return } - // 中途断开(服务重启 / 网络抖动):退避重连,期间没有新帧,曲线出现断口 + // 中途断开(服务重启 / 网络抖动 / 会话被收回):退避重连,期间没有新帧,曲线出现断口。 + // 会话失效时后端也会主动断开,刷新一次权限点:失效就跳登录页,降级就收起页面 + revalidateMe() wsFailures += 1 timer = setTimeout(connectSocket, Math.min(RETRY_MS * wsFailures, 10_000)) } @@ -228,7 +230,8 @@ async function pollOnce() { let ok = false try { const res = await fetch(`${API_BASE}/v1/live-rates`, { cache: 'no-store', signal: controller.signal }) - if (!res.ok) throw new Error(`HTTP ${res.status}`) + // 与其它接口同一套处理:会话失效(401)跳登录页,失去权限(403)刷新权限点、页面随之收起 + await handleResponse(res) const frames = (await res.json()) as LiveRateFrame[] if (controller.signal.aborted) return if (snapshot.transport !== 'poll') setTransport('poll') diff --git a/app/lib/use-me.ts b/app/lib/use-me.ts index ac53f27..26432c6 100644 --- a/app/lib/use-me.ts +++ b/app/lib/use-me.ts @@ -1,7 +1,12 @@ import useSWR from 'swr' -import { fetcher } from './api-streamer' +import { fetcher, ME_KEY } from './api-streamer' -/** 与后端 `Permission` 的 serde 名一一对应(crates/biliup-cli/src/server/infrastructure/permissions.rs) */ +export { ME_KEY } + +/** + * 与后端 `Permission` 的 serde 名一一对应(crates/biliup-cli/src/server/infrastructure/permissions.rs)。 + * 后端测试 `names_match_the_frontend_contract` 会读这个文件核对,两边增删必须同步。 + */ export type Permission = | 'streamer.view' | 'preview.view' @@ -17,6 +22,7 @@ export type Permission = | 'file.view' | 'user.manage' +/** 同上,与后端 `Role` 对应 */ export type Role = 'admin' | 'operator' | 'viewer' export const ROLE_LABELS: Record = { @@ -25,10 +31,21 @@ export const ROLE_LABELS: Record = { viewer: '只读观察者', } -export const ROLE_DESCRIPTIONS: Record = { - admin: '全部权限,包括用户管理、全局配置、B 站账号与后处理命令', - operator: '管理直播间、控制录制、编辑投稿模板和手动投稿', - viewer: '只能查看录制状态、预览、日志和录播文件', +/** 权限点的显示名。角色拥有哪些权限点由后端下发(`/v1/web-users/roles`),前端不再手写。 */ +export const PERMISSION_LABELS: Record = { + 'streamer.view': '查看直播间', + 'preview.view': '观看预览', + 'recording.control': '启停录制', + 'streamer.edit': '编辑直播间', + 'streamer.hooks': '后处理命令', + 'upload.submit': '手动投稿', + 'template.edit': '编辑投稿模板', + 'account.manage': '管理 B 站账号', + 'config.view': '查看配置', + 'config.edit': '修改配置', + 'log.view': '查看日志', + 'file.view': '查看录播文件', + 'user.manage': '管理用户', } export interface Me { @@ -36,20 +53,21 @@ export interface Me { id: number | null username: string | null role: Role + /** 后端授权决策点算出的实际权限(已考虑 `--auth` 是否开启等环境属性),前端只按它显隐 */ permissions: Permission[] /** 未开启 `--auth` 时为 false:零鉴权,视为超级管理员 */ auth_enabled: boolean } -export const ME_KEY = '/v1/me' - /** * 当前登录用户与权限点。按钮显隐只是体验,真正的拦截在后端。 * 未加载完成时 `can` 一律返回 false,宁可晚一点出现也不先露出再收回。 + * 回到页面时、以及任何请求收到 403 或长连接断开时都会重新拉取(见 `revalidateMe`), + * 角色被改后界面随之收起,不必手动刷新。 */ export function useMe() { const { data, error, isLoading } = useSWR(ME_KEY, fetcher, { - revalidateOnFocus: false, + revalidateOnFocus: true, dedupingInterval: 10_000, }) const permissions = data?.permissions diff --git a/app/ui/LivePreview.tsx b/app/ui/LivePreview.tsx index 2fa313d..6d3c29f 100644 --- a/app/ui/LivePreview.tsx +++ b/app/ui/LivePreview.tsx @@ -4,7 +4,7 @@ import dynamic from 'next/dynamic' import { Button, Modal, Radio, RadioGroup, Switch, Tag, Toast, Tooltip, Typography } from '@douyinfe/semi-ui' import { IconChevronDown, IconPlay, IconRefresh } from '@douyinfe/semi-icons' import type { ButtonProps } from '@douyinfe/semi-ui/lib/es/button' -import { LiveStreamerEntity } from '@/app/lib/api-streamer' +import { LiveStreamerEntity, revalidateMe } from '@/app/lib/api-streamer' import { platformName } from '@/app/lib/status' import { canPreview, @@ -182,6 +182,8 @@ export function LivePreviewPlayer({ const scheduleReconnect = useCallback( (why: string) => { + // 会话被收回时后端会截断预览流,表现为一次断流:刷新权限点,失效就跳登录页,降级就收起页面 + revalidateMe() if (attemptsRef.current >= MAX_AUTO_RECONNECT) { setPhase('ended') setMessage(`${why},已重连 ${MAX_AUTO_RECONNECT} 次`) diff --git a/crates/biliup-cli/build.rs b/crates/biliup-cli/build.rs new file mode 100644 index 0000000..346d7f6 --- /dev/null +++ b/crates/biliup-cli/build.rs @@ -0,0 +1,29 @@ +//! 迁移文件的原始字节就是 `_sqlx_migrations` 校验和的来源:同一份 SQL 换一种换行, +//! 编出来的二进制就内嵌另一套摘要,老库碰上另一种换行的构建便以 +//! `migration N was previously applied but has been modified` 起不来。仓库根目录的 +//! `.gitattributes` 把 `*.sql` 固定成 LF,这里在编译期兜底:带 CR 的迁移进不了二进制。 + +use std::fs; +use std::path::Path; + +fn main() { + // `sqlx::migrate!()` 只追踪已有的迁移文件,新增迁移时靠这一行触发重新编译(sqlx 官方做法)。 + println!("cargo:rerun-if-changed=migrations"); + + let dir = Path::new(&std::env::var("CARGO_MANIFEST_DIR").unwrap()).join("migrations"); + for entry in fs::read_dir(&dir).expect("无法读取 migrations 目录") { + let path = entry.expect("无法读取 migrations 目录").path(); + if path.extension().is_none_or(|ext| ext != "sql") { + continue; + } + let sql = + fs::read(&path).unwrap_or_else(|err| panic!("无法读取 {}: {err}", path.display())); + assert!( + !sql.contains(&b'\r'), + "{} 含 CR 换行。迁移文件必须是 LF(见仓库根目录 .gitattributes),否则不同平台编出来的\ + 二进制会记下不同的校验和。.gitattributes 生效前检出的文件不会自动转换:没有本地改动的话,\ + 删掉它后在仓库根目录执行 `git checkout -- crates/biliup-cli/migrations` 重新检出即可。", + path.display() + ); + } +} diff --git a/crates/biliup-cli/src/server/api/access.rs b/crates/biliup-cli/src/server/api/access.rs index 3964662..3f4dac0 100644 --- a/crates/biliup-cli/src/server/api/access.rs +++ b/crates/biliup-cli/src/server/api/access.rs @@ -1,9 +1,8 @@ //! 业务路由的访问控制:登录校验 + 按路由表的权限校验(默认拒绝), //! 以及长连接在握手之后的定期复查。 -use crate::server::infrastructure::permissions::{ - Permission, Role, required_permission, route_allowed, -}; +use crate::server::infrastructure::permissions::Permission; +use crate::server::infrastructure::policy::{Field, RouteRequirement, Subject}; use crate::server::infrastructure::users::{AuthSession, Backend}; use axum::Json; use axum::body::Body; @@ -23,11 +22,11 @@ pub const RECHECK_INTERVAL: Duration = Duration::from_secs(30); #[cfg(test)] pub const RECHECK_INTERVAL: Duration = Duration::from_millis(100); -/// 当前请求的调用者。由访问控制中间件放进请求扩展,处理函数据此决定脱敏与字段保护。 +/// 当前请求的调用者。由访问控制中间件放进请求扩展,处理函数据此决定脱敏与字段保护; +/// 判断本身都在 [`crate::server::infrastructure::policy`]。 #[derive(Clone)] pub struct Caller { - pub user_id: Option, - pub role: Role, + pub subject: Subject, watch: Option, } @@ -35,14 +34,17 @@ impl Caller { /// `--auth` 关闭时:零鉴权,视为超管。 pub fn unrestricted() -> Self { Caller { - user_id: None, - role: Role::Admin, + subject: Subject::unrestricted(), watch: None, } } pub fn can(&self, permission: Permission) -> bool { - self.role.has(permission) + self.subject.can(permission) + } + + pub fn can_access(&self, field: Field) -> bool { + self.subject.can_access(field) } /// 会话失效(禁用、删除、改密、强制下线)或失去这条路由的权限时完成;`--auth` 关闭时永不完成。 @@ -72,7 +74,7 @@ struct SessionWatch { backend: Backend, user_id: i64, auth_hash: Vec, - permission: Option, + requirement: RouteRequirement, } impl SessionWatch { @@ -80,10 +82,7 @@ impl SessionWatch { let user = self.backend.get_user(&self.user_id).await?; Ok(user.is_some_and(|user| { user.session_auth_hash() == self.auth_hash.as_slice() - && (user.role == Role::Admin - || self - .permission - .is_some_and(|permission| user.role.has(permission))) + && Subject::user(user.id, user.role).satisfies(self.requirement) })) } @@ -124,20 +123,19 @@ pub async fn require_permission( .get::() .map(|path| path.as_str().to_owned()) .unwrap_or_else(|| request.uri().path().to_owned()); - let method = request.method().clone(); - let raw_path = request.uri().path().to_owned(); - if !route_allowed(user.role, &method, &route, &raw_path) { + let requirement = RouteRequirement::of(request.method(), &route, request.uri().path()); + let subject = Subject::user(user.id, user.role); + if !subject.satisfies(requirement) { return forbidden(); } let watch = SessionWatch { backend: auth_session.backend.clone(), user_id: user.id, auth_hash: user.session_auth_hash().to_vec(), - permission: required_permission(&method, &route, &raw_path), + requirement, }; let caller = Caller { - user_id: Some(user.id), - role: user.role, + subject, watch: Some(watch), }; request.extensions_mut().insert(caller.clone()); @@ -165,6 +163,7 @@ fn cut_off_when_revoked(response: Response, caller: &Caller) -> Response { mod tests { use super::*; use crate::server::infrastructure::connection_pool::ConnectionManager; + use crate::server::infrastructure::permissions::Role; use crate::server::infrastructure::users::{Credentials, UserChanges}; use axum::Router; use axum::http::Method; @@ -214,6 +213,7 @@ mod tests { ("GET", "/static/a.flv"), ("GET", "/v1/ws/logs"), ("GET", "/v1/web-users"), + ("GET", "/v1/web-users/roles"), ("POST", "/v1/web-users"), ("PUT", "/v1/web-users/1"), ("DELETE", "/v1/web-users/1"), @@ -301,6 +301,7 @@ mod tests { .route("/static/{path}", ok()) .route("/v1/ws/logs", ok()) .route("/v1/web-users", any()) + .route("/v1/web-users/roles", ok()) .route("/v1/web-users/{id}", any()) .route( "/v1/web-users/{id}/logout-all", @@ -488,7 +489,7 @@ mod tests { backend: backend.clone(), user_id: op.id, auth_hash: op.session_auth_hash().to_vec(), - permission: Some(Permission::PreviewView), + requirement: RouteRequirement::Permission(Permission::PreviewView), }; assert!(watch.still_valid().await.unwrap()); @@ -500,7 +501,7 @@ mod tests { assert!(watch.still_valid().await.unwrap(), "只读也能看预览"); let log_watch = SessionWatch { - permission: Some(Permission::RecordingControl), + requirement: RouteRequirement::Permission(Permission::RecordingControl), ..watch.clone() }; assert!(!log_watch.still_valid().await.unwrap(), "失去该路由的权限"); @@ -515,13 +516,12 @@ mod tests { seed(&backend).await; let op = backend.find_by_username("op").await.unwrap().unwrap(); let caller = Caller { - user_id: Some(op.id), - role: op.role, + subject: Subject::user(op.id, op.role), watch: Some(SessionWatch { backend: backend.clone(), user_id: op.id, auth_hash: op.session_auth_hash().to_vec(), - permission: Some(Permission::PreviewView), + requirement: RouteRequirement::Permission(Permission::PreviewView), }), }; let endless = futures::stream::unfold((), |()| async { diff --git a/crates/biliup-cli/src/server/api/endpoints.rs b/crates/biliup-cli/src/server/api/endpoints.rs index 315a3ee..e11ff49 100644 --- a/crates/biliup-cli/src/server/api/endpoints.rs +++ b/crates/biliup-cli/src/server/api/endpoints.rs @@ -15,7 +15,7 @@ use crate::server::infrastructure::models::upload_streamer::{ InsertUploadStreamer, UploadStreamer, }; use crate::server::infrastructure::models::{Configuration, FileItem, StreamerInfo}; -use crate::server::infrastructure::permissions::Permission; +use crate::server::infrastructure::policy::{Field, Subject}; use crate::server::infrastructure::repositories::{ del_streamer, delete_bilibili_cookie, get_all_streamer, get_upload_config, register_bilibili_cookie, @@ -62,7 +62,7 @@ pub async fn get_streamers_endpoint( let live_streamers = get_all_streamer(&pool).await.map_err(report_to_response)?; let mut results = Vec::new(); let workers = managers.get_rooms().await; - let show_hooks = caller.can(Permission::StreamerHooks); + let show_hooks = caller.can_access(Field::StreamerHooks); for mut x in live_streamers { let option = workers .clone() @@ -118,7 +118,7 @@ pub async fn get_streamers_endpoint( Ok(Json(results)) } -/// 钩子走 `sh -c`、`override` 能覆盖任意全局配置,两者合起来等于服务器 shell,只给有 `streamer.hooks` 的人看。 +/// 钩子走 `sh -c`、`override` 能覆盖任意全局配置,两者合起来等于服务器 shell,见 [`Field::StreamerHooks`]。 fn strip_hooks(streamer: &mut LiveStreamer) { streamer.override_cfg = None; streamer.preprocessor = None; @@ -134,7 +134,7 @@ pub async fn post_streamers_endpoint( State(pool): State, Json(mut payload): Json, ) -> Result, Response> { - if !caller.can(Permission::StreamerHooks) { + if !caller.can_access(Field::StreamerHooks) { payload.override_cfg = None; payload.preprocessor = None; payload.segment_processor = None; @@ -170,7 +170,7 @@ pub async fn put_streamers_endpoint( State(pool): State, Json(mut payload): Json, ) -> Result, Response> { - if !caller.can(Permission::StreamerHooks) { + if !caller.can_access(Field::StreamerHooks) { // 表单是整体覆盖保存;没有钩子权限的人看不到这几项,这里以库里原值为准,不看请求体。 let current = LiveStreamer::select() .where_("id = ?") @@ -207,7 +207,7 @@ pub async fn put_streamers_endpoint( info!(id = id, "successfully update live streamers"); let mut streamer = streamer; - if !caller.can(Permission::StreamerHooks) { + if !caller.can_access(Field::StreamerHooks) { strip_hooks(&mut streamer); } Ok(Json(streamer)) @@ -265,7 +265,7 @@ pub async fn get_configuration( State(config): State>>, ) -> Result, Response> { let config = config.read().unwrap().clone(); - if caller.can(Permission::ConfigEdit) { + if caller.can_access(Field::ConfigSecrets) { return serde_json::to_value(config) .map(Json) .change_context(AppError::Unknown) @@ -423,12 +423,18 @@ pub async fn get_upload_streamers_endpoint( Ok(Json(uploader_streamers)) } -/// 非超管保存投稿模板时的两处限制:投稿账号只能从已登记的 B 站账号里选(新增账号归账号管理); -/// 封面路径界面上不提供编辑,而它会被原样读出来上传,所以只保留原值。 -async fn restrict_template_for_non_admin( +/// 保存投稿模板时的两个受保护字段:没有 [`Field::TemplateAccount`] 的人只能从已登记的 +/// B 站账号里选(新增账号归账号管理);没有 [`Field::TemplateCoverPath`] 的人封面路径只保留原值。 +async fn protect_template_fields( + subject: &Subject, pool: &ConnectionPool, upload_streamer: &mut InsertUploadStreamer, ) -> Result<(), Response> { + let keep_cover = !subject.can_access(Field::TemplateCoverPath); + let check_account = !subject.can_access(Field::TemplateAccount); + if !keep_cover && !check_account { + return Ok(()); + } let current = match upload_streamer.id { Some(id) => UploadStreamer::select() .where_("id = ?") @@ -439,9 +445,14 @@ async fn restrict_template_for_non_admin( .map_err(report_to_response)?, None => None, }; - upload_streamer.cover_path = current - .as_ref() - .and_then(|template| template.cover_path.clone()); + if keep_cover { + upload_streamer.cover_path = current + .as_ref() + .and_then(|template| template.cover_path.clone()); + } + if !check_account { + return Ok(()); + } let Some(cookie) = upload_streamer .user_cookie .as_deref() @@ -474,9 +485,7 @@ pub async fn add_upload_streamer_endpoint( State(pool): State, Json(mut upload_streamer): Json, ) -> Result, Response> { - if !caller.can(Permission::AccountManage) { - restrict_template_for_non_admin(&pool, &mut upload_streamer).await?; - } + protect_template_fields(&caller.subject, &pool, &mut upload_streamer).await?; if upload_streamer.id.is_none() { Ok(Json( serde_json::to_value( @@ -624,6 +633,80 @@ mod user_payload_tests { } } +#[cfg(test)] +mod template_field_tests { + use super::protect_template_fields; + use crate::server::infrastructure::connection_pool::ConnectionManager; + use crate::server::infrastructure::models::upload_streamer::InsertUploadStreamer; + use crate::server::infrastructure::permissions::Role; + use crate::server::infrastructure::policy::Subject; + use axum::http::StatusCode; + use ormlite::Model; + + fn template(id: Option, cookie: &str, cover: &str) -> InsertUploadStreamer { + serde_json::from_value(serde_json::json!({ + "id": id, + "template_name": "t", + "user_cookie": cookie, + "cover_path": cover, + "tags": [], + })) + .unwrap() + } + + #[tokio::test] + async fn only_account_managers_bind_any_account_and_set_the_cover_path() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("data.sqlite3"); + let pool = ConnectionManager::new_pool(db.to_str().unwrap()) + .await + .unwrap(); + sqlx::query("INSERT INTO configuration (key, value) VALUES ('bilibili-cookies', 'a.json')") + .execute(&pool) + .await + .unwrap(); + let saved = template(None, "old.json", "/srv/cover.jpg") + .insert(&pool) + .await + .unwrap(); + + let operator = Subject::user(2, Role::Operator); + // 已登记的账号可以选,封面路径回到库里原值 + let mut edit = template(saved.id, "a.json", "/etc/passwd"); + protect_template_fields(&operator, &pool, &mut edit) + .await + .unwrap(); + assert_eq!(edit.cover_path.as_deref(), Some("/srv/cover.jpg")); + assert_eq!(edit.user_cookie.as_deref(), Some("a.json")); + // 模板原来绑的账号即使没登记也可以保留 + let mut keep = template(saved.id, "old.json", ""); + protect_template_fields(&operator, &pool, &mut keep) + .await + .unwrap(); + // 没登记的账号不行 + let mut other = template(saved.id, "/tmp/evil.json", ""); + let rejected = protect_template_fields(&operator, &pool, &mut other) + .await + .unwrap_err(); + assert_eq!(rejected.status(), StatusCode::FORBIDDEN); + // 新建模板没有原值,封面路径清空 + let mut created = template(None, "a.json", "/etc/passwd"); + protect_template_fields(&operator, &pool, &mut created) + .await + .unwrap(); + assert_eq!(created.cover_path, None); + + for admin in [Subject::user(1, Role::Admin), Subject::unrestricted()] { + let mut edit = template(saved.id, "/tmp/any.json", "/srv/new.jpg"); + protect_template_fields(&admin, &pool, &mut edit) + .await + .unwrap(); + assert_eq!(edit.cover_path.as_deref(), Some("/srv/new.jpg")); + assert_eq!(edit.user_cookie.as_deref(), Some("/tmp/any.json")); + } + } +} + pub async fn delete_user_endpoint( Path(id): Path, State(pool): State, @@ -732,7 +815,8 @@ pub async fn get_status( ) -> Result, Response> { let workers = managers.get_rooms().await; - let full = caller.can(Permission::ConfigEdit); + let show_hooks = caller.can_access(Field::StreamerHooks); + let show_account = caller.can_access(Field::TemplateAccount); let mut sw = Vec::new(); for worker in &workers { let mut room = serde_json::json!({ @@ -741,13 +825,15 @@ pub async fn get_status( "live_streamer": worker.live_streamer, "upload_streamer": worker.upload_streamer, }); - if !full { + if !show_hooks { redact::streamer_hooks(&mut room["live_streamer"]); + } + if !show_account { redact::upload_template(&mut room["upload_streamer"]); } sw.push(room); } - let config = if full { + let config = if caller.can_access(Field::ConfigSecrets) { serde_json::to_value(&*config.read().unwrap()).unwrap_or_default() } else { redact::config(&*config.read().unwrap()) diff --git a/crates/biliup-cli/src/server/api/web_users.rs b/crates/biliup-cli/src/server/api/web_users.rs index f2a07f0..898f52e 100644 --- a/crates/biliup-cli/src/server/api/web_users.rs +++ b/crates/biliup-cli/src/server/api/web_users.rs @@ -2,6 +2,7 @@ use crate::server::api::access::Caller; use crate::server::infrastructure::permissions::Role; +use crate::server::infrastructure::policy::Subject; use crate::server::infrastructure::users::{ AuthSession, CreateUserError, UpdateUserError, UserChanges, UserSummary, }; @@ -17,6 +18,7 @@ use serde_json::json; pub fn admin_router() -> Router<()> { Router::new() .route("/v1/web-users", get(list_users).post(create_user)) + .route("/v1/web-users/roles", get(list_roles)) .route("/v1/web-users/{id}", put(update_user).delete(delete_user)) .route("/v1/web-users/{id}/logout-all", post(logout_all)) } @@ -42,26 +44,27 @@ fn internal(error: impl std::fmt::Debug) -> Response { StatusCode::INTERNAL_SERVER_ERROR.into_response() } -fn me_body(username: Option<&str>, id: Option, role: Role, auth_enabled: bool) -> Response { +/// 权限点由授权决策点算出(含环境属性),前端只按它显隐,不再自己推导。 +fn me_body(username: Option<&str>, subject: Subject) -> Response { Json(json!({ - "id": id, + "id": subject.user_id, "username": username, - "role": role, - "permissions": role.permissions(), - "auth_enabled": auth_enabled, + "role": subject.role, + "permissions": subject.permissions(), + "auth_enabled": subject.auth_enabled, })) .into_response() } async fn me(auth_session: AuthSession) -> Response { match auth_session.user { - Some(user) => me_body(Some(&user.username), Some(user.id), user.role, true), + Some(user) => me_body(Some(&user.username), Subject::user(user.id, user.role)), None => StatusCode::UNAUTHORIZED.into_response(), } } async fn unrestricted_me() -> Response { - me_body(None, None, Role::Admin, false) + me_body(None, Subject::unrestricted()) } #[derive(Deserialize)] @@ -95,6 +98,23 @@ async fn change_password( StatusCode::NO_CONTENT.into_response() } +/// 各角色实际拥有的权限点,用户管理页据此说明角色,前端不再手写角色与权限的对应。 +async fn list_roles() -> Response { + let roles: Vec<_> = Role::ALL + .into_iter() + .map(|role| { + // 这个接口只在 `--auth` 开启时存在,按开启时的环境算 + let subject = Subject { + user_id: None, + role, + auth_enabled: true, + }; + json!({ "role": role, "permissions": subject.permissions() }) + }) + .collect(); + Json(roles).into_response() +} + async fn list_users(auth_session: AuthSession) -> Response { match auth_session.backend.list_users().await { Ok(users) => Json(users.iter().map(UserSummary::from).collect::>()).into_response(), @@ -140,7 +160,9 @@ async fn update_user( Path(id): Path, Json(changes): Json, ) -> Response { - if caller.user_id == Some(id) && (changes.role.is_some() || changes.disabled == Some(true)) { + if caller.subject.user_id == Some(id) + && (changes.role.is_some() || changes.disabled == Some(true)) + { return message(StatusCode::BAD_REQUEST, "不能修改自己的角色或禁用自己"); } match auth_session.backend.update_user(id, changes).await { @@ -150,7 +172,7 @@ async fn update_user( } async fn delete_user(caller: Caller, auth_session: AuthSession, Path(id): Path) -> Response { - if caller.user_id == Some(id) { + if caller.subject.user_id == Some(id) { return message(StatusCode::BAD_REQUEST, "不能删除自己"); } match auth_session.backend.delete_user(id).await { @@ -170,6 +192,7 @@ async fn logout_all(auth_session: AuthSession, Path(id): Path) -> Response mod tests { use crate::server::api::{access, auth}; use crate::server::infrastructure::connection_pool::ConnectionManager; + use crate::server::infrastructure::permissions::Role; use crate::server::infrastructure::users::Backend; use axum::Router; use axum::body::Body; @@ -355,6 +378,54 @@ mod tests { assert_eq!(status, StatusCode::NO_CONTENT); } + #[tokio::test] + async fn role_catalog_comes_from_the_policy() { + let (_dir, app) = app().await; + let (_, admin, _) = call( + &app, + None, + "POST", + "/v1/users/register", + Some(json!({ "username": "biliup", "password": "admin-password" })), + ) + .await; + let admin = admin.unwrap(); + let (status, _, roles) = call(&app, Some(&admin), "GET", "/v1/web-users/roles", None).await; + assert_eq!(status, StatusCode::OK); + let roles = roles.as_array().unwrap(); + assert_eq!(roles.len(), Role::ALL.len()); + for (entry, role) in roles.iter().zip(Role::ALL) { + assert_eq!(entry["role"], json!(role)); + assert_eq!(entry["permissions"], json!(role.permissions())); + } + + let (status, _, _) = call( + &app, + Some(&admin), + "POST", + "/v1/web-users", + Some(json!({ "username": "ro", "password": "viewer-password", "role": "viewer" })), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + let viewer = login(&app, "ro", "viewer-password").await; + let (status, _, _) = call(&app, Some(&viewer), "GET", "/v1/web-users/roles", None).await; + assert_eq!(status, StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn without_auth_there_are_no_users_to_manage() { + let app = super::unrestricted_me_router(); + let (status, _, me) = call(&app, None, "GET", "/v1/me", None).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(me["auth_enabled"], false); + assert_eq!(me["role"], "admin"); + let permissions = me["permissions"].as_array().unwrap(); + assert!(!permissions.contains(&json!("user.manage"))); + assert!(permissions.contains(&json!("streamer.hooks"))); + assert!(permissions.contains(&json!("config.edit"))); + } + #[tokio::test] async fn admins_cannot_lock_themselves_out() { let (_dir, app) = app().await; diff --git a/crates/biliup-cli/src/server/infrastructure/connection_pool.rs b/crates/biliup-cli/src/server/infrastructure/connection_pool.rs index 053a1bc..934f2e5 100644 --- a/crates/biliup-cli/src/server/infrastructure/connection_pool.rs +++ b/crates/biliup-cli/src/server/infrastructure/connection_pool.rs @@ -1,8 +1,9 @@ use crate::server::errors::{AppError, AppResult}; use error_stack::ResultExt; -use sqlx::migrate::Migrator; +use sqlx::migrate::{Migration, Migrator}; use sqlx::sqlite::SqlitePoolOptions; use sqlx::{Pool, Sqlite}; +use std::borrow::Cow; use std::fmt::Write as _; use std::path::Path; use tracing::{info, warn}; @@ -12,11 +13,14 @@ pub type ConnectionPool = Pool; /// 一条曾经被就地改写过的迁移。 /// -/// `legacy_checksum` 是历史版本里该文件内容的 SHA-384(sqlx 记在 `_sqlx_migrations` +/// `legacy_checksums` 是历史版本里该文件内容的 SHA-384(sqlx 记在 `_sqlx_migrations` /// 里的那一列),`repair` 是把旧内容的执行效果补成新内容所需的语句。 struct SupersededMigration { version: i64, - legacy_checksum: &'static str, + /// 旧内容在 LF 与 CRLF 检出下的摘要都要登记:`.gitattributes` 把迁移统一成 LF 之前, + /// Windows 发布包(`windows-latest` 上以 `core.autocrlf=true` 检出)和 Windows 本地 + /// 构建记进库里的都是 CRLF 摘要。 + legacy_checksums: &'static [&'static str], /// 必须幂等:老库可能已经处于新内容期望的状态,也可能重复启动多次。 repair: &'static [&'static str], } @@ -35,7 +39,12 @@ const SUPERSEDED_MIGRATIONS: &[SupersededMigration] = &[ // 全部校验失配。两版内容的唯一差异就是这一条 UPDATE。 SupersededMigration { version: 2, - legacy_checksum: "fcc6436a889297e5c28f2a0f12196e5eee5975ba352032c8201dfa61fb1b9c3fd01ddc41ddfa71bd7423999521b895eb", + legacy_checksums: &[ + // LF 检出:Linux / macOS / Docker 构建 + "fcc6436a889297e5c28f2a0f12196e5eee5975ba352032c8201dfa61fb1b9c3fd01ddc41ddfa71bd7423999521b895eb", + // CRLF 检出:v1.1.8 ~ v1.2.4 的 Windows 发布包内嵌的就是这个,Windows 本地构建同理 + "f636cd5b7b62ae53f422ee22daa1c957ddb9d932a9b1c8061fda58adb921032fae46f7896f55ea561158ff963b8ea038", + ], repair: &["UPDATE uploadstreamers SET tags = '[]' WHERE tags = '' OR tags = 'null'"], }, ]; @@ -81,7 +90,7 @@ impl ConnectionManager { // 运行数据库迁移,确保数据库结构是最新的 let migrator = sqlx::migrate!(); - Self::reconcile_superseded_migrations(&pool, &migrator).await?; + Self::reconcile_applied_migrations(&pool, &migrator).await?; info!("migrations enabled, running..."); migrator.run(&pool).await.change_context(AppError::Custom( @@ -91,12 +100,16 @@ impl ConnectionManager { Ok(pool) } - /// 把历史上被改写过的迁移记录对齐到当前文件内容,让老库还能继续升级。 + /// 在 sqlx 校验之前,把两类可以放行的校验和失配对齐到当前二进制内嵌的摘要,让老库 + /// 还能继续升级: /// - /// 只在记录里的校验和**恰好等于**登记过的历史摘要时才动手;其余任何失配都原样 - /// 留给 sqlx 报错,免得把用户自己改过的迁移悄悄放行。补丁语句与校验和改写在同一 - /// 个事务里,中途崩溃不会留下「补丁没跑但校验和已对齐」的中间态。 - async fn reconcile_superseded_migrations( + /// - 内容没变、只是换行不同:`.gitattributes` 把迁移统一成 LF 之前,Windows 发布包和 + /// Windows 本地构建记进库里的都是 CRLF 检出的摘要。只改写校验和,不补跑任何语句。 + /// - 记录**恰好等于** `SUPERSEDED_MIGRATIONS` 里登记过的历史摘要:补丁语句与校验和 + /// 改写在同一个事务里,中途崩溃不会留下「补丁没跑但校验和已对齐」的中间态。 + /// + /// 其余任何失配都原样留给 sqlx 报错,免得把用户自己改过的迁移悄悄放行。 + async fn reconcile_applied_migrations( pool: &ConnectionPool, migrator: &Migrator, ) -> AppResult<()> { @@ -113,33 +126,55 @@ impl ConnectionManager { return Ok(()); } - for entry in SUPERSEDED_MIGRATIONS { - let Some(current) = migrator.iter().find(|m| m.version == entry.version) else { - continue; - }; - let applied: Option<(Vec, bool)> = - sqlx::query_as("SELECT checksum, success FROM _sqlx_migrations WHERE version = ?") - .bind(entry.version) - .fetch_optional(pool) - .await - .change_context(AppError::Custom( - "error while inspecting database migration state".to_string(), - ))?; - // 没跑过(含跑失败留下的记录)的迁移由 sqlx 正常应用,不需要对齐。 - let Some((checksum, true)) = applied else { + // 没跑过(含跑失败留下的记录)的迁移由 sqlx 正常应用,不需要对齐。 + let applied: Vec<(i64, Vec)> = sqlx::query_as( + "SELECT version, checksum FROM _sqlx_migrations WHERE success = TRUE ORDER BY version", + ) + .fetch_all(pool) + .await + .change_context(AppError::Custom( + "error while inspecting database migration state".to_string(), + ))?; + + for (version, checksum) in applied { + let Some(current) = migrator + .iter() + .find(|m| m.version == version && !m.migration_type.is_down_migration()) + else { continue; }; if checksum == current.checksum.as_ref() { continue; } - if hex_lower(&checksum) != entry.legacy_checksum { - warn!( - version = entry.version, - "已应用的迁移既不是当前内容也不是已知的历史内容,不做自动对齐" + + if checksum == crlf_checksum(current) { + sqlx::query("UPDATE _sqlx_migrations SET checksum = ? WHERE version = ?") + .bind(current.checksum.to_vec()) + .bind(version) + .execute(pool) + .await + .change_context(AppError::Custom( + "error while reconciling database migration state".to_string(), + ))?; + info!( + version, + "该迁移记录的是 CRLF 检出下的摘要,内容与当前一致,已对齐校验和" ); continue; } + let applied_checksum = hex_lower(&checksum); + let Some(entry) = SUPERSEDED_MIGRATIONS.iter().find(|entry| { + entry.version == version + && entry.legacy_checksums.contains(&applied_checksum.as_str()) + }) else { + warn!( + version, + "已应用的迁移既不是当前内容也不是已知的历史内容,不做自动对齐" + ); + continue; + }; + let mut tx = pool.begin().await.change_context(AppError::Custom( "error while reconciling database migration state".to_string(), ))?; @@ -153,7 +188,7 @@ impl ConnectionManager { } sqlx::query("UPDATE _sqlx_migrations SET checksum = ? WHERE version = ?") .bind(current.checksum.to_vec()) - .bind(entry.version) + .bind(version) .execute(&mut *tx) .await .change_context(AppError::Custom( @@ -163,16 +198,30 @@ impl ConnectionManager { "error while reconciling database migration state".to_string(), ))?; - info!( - version = entry.version, - "该迁移在新版本中被修正,已补跑差异并对齐校验和" - ); + info!(version, "该迁移在新版本中被修正,已补跑差异并对齐校验和"); } Ok(()) } } +/// 同一份迁移内容以 CRLF 检出时 sqlx 会记下的校验和。 +/// +/// 内嵌的 SQL 一定是 LF(`build.rs` 在编译期把关),把每个 `\n` 换成 `\r\n` 就是 +/// `core.autocrlf=true` 检出的字节;摘要交给 sqlx 自己的 `Migration::new` 算,与它记账的 +/// 算法保持一致。 +fn crlf_checksum(migration: &Migration) -> Vec { + Migration::new( + migration.version, + migration.description.clone(), + migration.migration_type, + Cow::Owned(migration.sql.replace('\n', "\r\n")), + migration.no_tx, + ) + .checksum + .into_owned() +} + fn hex_lower(bytes: &[u8]) -> String { bytes .iter() @@ -184,7 +233,12 @@ fn hex_lower(bytes: &[u8]) -> String { #[cfg(test)] mod tests { - use super::{ConnectionManager, ConnectionPool, SUPERSEDED_MIGRATIONS, hex_lower}; + use super::{ConnectionManager, ConnectionPool, crlf_checksum, hex_lower}; + + /// v1.1.8 ~ v1.2.4 发布包给迁移 2(改写前的内容)记下的摘要:Linux / macOS / Docker + /// 包是 LF 检出,Windows 包是在 `windows-latest` 上以 CRLF 检出后编的。 + const V1_2_4_MIGRATION_2_LF: &str = "fcc6436a889297e5c28f2a0f12196e5eee5975ba352032c8201dfa61fb1b9c3fd01ddc41ddfa71bd7423999521b895eb"; + const V1_2_4_MIGRATION_2_CRLF: &str = "f636cd5b7b62ae53f422ee22daa1c957ddb9d932a9b1c8061fda58adb921032fae46f7896f55ea561158ff963b8ea038"; fn decode_hex(hex: &str) -> Vec { (0..hex.len()) @@ -193,6 +247,15 @@ mod tests { .collect() } + async fn set_checksum(pool: &ConnectionPool, version: i64, checksum: &[u8]) { + sqlx::query("UPDATE _sqlx_migrations SET checksum = ? WHERE version = ?") + .bind(checksum.to_vec()) + .bind(version) + .execute(pool) + .await + .unwrap(); + } + /// 把一个全新库回退成 v1.2.4 那一代的样子:迁移 4/5 尚未应用,迁移 2 记的是 /// 改写前的校验和。 async fn rewind_to_v1_2_4(pool: &ConnectionPool, migration_2_checksum: &[u8]) { @@ -219,8 +282,8 @@ mod tests { /// /// 这个断言挂掉说明有人改动了一条**已经发布过**的迁移 —— 所有老库都会在升级时 /// 以校验和失配启动失败(#1701 就是这么来的)。正确做法是把改动挪进一个新的迁移 - /// 文件;确实只能就地改的话,把旧摘要连同幂等补丁登记进 `SUPERSEDED_MIGRATIONS`, - /// 再更新这里。 + /// 文件;确实只能就地改的话,把旧内容在 LF / CRLF 两种检出下的摘要连同幂等补丁 + /// 登记进 `SUPERSEDED_MIGRATIONS`,再更新这里。 #[test] fn shipped_migration_checksums_are_frozen() { const FROZEN: &[(i64, &str)] = &[ @@ -264,12 +327,29 @@ mod tests { /// 升级必须照常完成:待应用的迁移要跑完,既有数据不能丢。 #[tokio::test] async fn upgrade_from_pre_1_2_5_database_reconciles_rewritten_migration() { + assert_upgrades_from_v1_2_4(V1_2_4_MIGRATION_2_LF, false).await; + } + + /// 同一代的库,但由 Windows 发布包建立:迁移 1/3 内容没变、记的是 CRLF 摘要,迁移 2 + /// 记的是改写前内容的 CRLF 摘要。两种失配都得在同一次启动里对齐。 + #[tokio::test] + async fn upgrade_from_pre_1_2_5_windows_release_database() { + assert_upgrades_from_v1_2_4(V1_2_4_MIGRATION_2_CRLF, true).await; + } + + async fn assert_upgrades_from_v1_2_4(migration_2_checksum: &str, crlf_checkout: bool) { let dir = tempfile::tempdir().unwrap(); let db = dir.path().join("data.sqlite3"); let pool = ConnectionManager::new_pool(db.to_str().unwrap()) .await .unwrap(); - rewind_to_v1_2_4(&pool, &decode_hex(SUPERSEDED_MIGRATIONS[0].legacy_checksum)).await; + rewind_to_v1_2_4(&pool, &decode_hex(migration_2_checksum)).await; + if crlf_checkout { + let embedded = sqlx::migrate!(); + for migration in embedded.iter().filter(|m| m.version == 1 || m.version == 3) { + set_checksum(&pool, migration.version, &crlf_checksum(migration)).await; + } + } sqlx::query( "INSERT INTO uploadstreamers (id, template_name, tags) VALUES \ (1, 'from-python', ''), (2, 'normal', '[\"直播录像\"]')", @@ -301,12 +381,14 @@ mod tests { "待应用的迁移必须补齐" ); let embedded = sqlx::migrate!(); - let expected = embedded.iter().find(|m| m.version == 2).unwrap(); - assert_eq!( - hex_lower(&migrations[1].1), - hex_lower(&expected.checksum), - "迁移 2 的校验和必须对齐到当前文件内容" - ); + for (version, checksum) in &migrations { + let expected = embedded.iter().find(|m| m.version == *version).unwrap(); + assert_eq!( + hex_lower(checksum), + hex_lower(&expected.checksum), + "迁移 {version} 的校验和必须对齐到当前内嵌的摘要" + ); + } let tags: Vec<(i64, String)> = sqlx::query_as("SELECT id, tags FROM uploadstreamers ORDER BY id") @@ -328,6 +410,74 @@ mod tests { .expect("迁移 5 必须已应用"); } + /// v1.2.5 起的 Windows 发布包(以及 Windows 本地构建)建立的库:内容与当前完全一致, + /// 只是每条记录都是 CRLF 摘要。只改写校验和,不能借机补跑任何语句。 + #[tokio::test] + async fn database_recorded_from_crlf_checkout_is_realigned_without_replaying() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("data.sqlite3"); + let pool = ConnectionManager::new_pool(db.to_str().unwrap()) + .await + .unwrap(); + let embedded = sqlx::migrate!(); + for migration in embedded.iter() { + set_checksum(&pool, migration.version, &crlf_checksum(migration)).await; + } + // 迁移 2 的补丁会把空串 tags 改成 '[]',只是换行不同时不该碰到它。 + sqlx::query( + "INSERT INTO uploadstreamers (id, template_name, tags) VALUES (1, 'untouched', '')", + ) + .execute(&pool) + .await + .unwrap(); + pool.close().await; + + let pool = ConnectionManager::new_pool(db.to_str().unwrap()) + .await + .expect("CRLF 检出下建立的库必须能直接启动"); + + let recorded: Vec<(i64, Vec)> = + sqlx::query_as("SELECT version, checksum FROM _sqlx_migrations ORDER BY version") + .fetch_all(&pool) + .await + .unwrap(); + assert_eq!( + recorded + .iter() + .map(|(version, checksum)| (*version, hex_lower(checksum))) + .collect::>(), + embedded + .iter() + .map(|m| (m.version, hex_lower(&m.checksum))) + .collect::>(), + ); + let tags: String = sqlx::query_scalar("SELECT tags FROM uploadstreamers WHERE id = 1") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(tags, "", "只是换行不同,不能补跑迁移 2 的补丁"); + } + + /// `crlf_checksum` 必须复现 Windows 发布包真实记下的摘要。两个值取自官方 v1.2.7 + /// Windows 包(`biliupR-v1.2.7-x86_64-windows.zip`)内嵌的迁移表。 + #[test] + fn crlf_checksum_matches_windows_release_builds() { + let embedded = sqlx::migrate!(); + let crlf = |version| { + hex_lower(&crlf_checksum( + embedded.iter().find(|m| m.version == version).unwrap(), + )) + }; + assert_eq!( + crlf(1), + "582585a2cecf6e9b4d1ae9edb1a2fac80b080106a9384b2e4d9be4c158dedd32f6c3c0c5c396634e7f5679d4221c3878" + ); + assert_eq!( + crlf(2), + "10f4a226e937d35e0e49aa2995f3637150135dda5bfee0a4d9caeb7df4f4c1674cb18416901dfd87a4fadb6dc6577414" + ); + } + /// 自愈只针对登记在册的历史内容。用户自己改过的迁移仍然要报错,不能被悄悄放行。 #[tokio::test] async fn unknown_migration_checksum_is_not_silently_reconciled() { diff --git a/crates/biliup-cli/src/server/infrastructure/mod.rs b/crates/biliup-cli/src/server/infrastructure/mod.rs index 8856ffb..e1aec5c 100644 --- a/crates/biliup-cli/src/server/infrastructure/mod.rs +++ b/crates/biliup-cli/src/server/infrastructure/mod.rs @@ -8,6 +8,8 @@ pub mod dto; pub mod models; /// Web 用户的角色与权限点 pub mod permissions; +/// 授权决策点:按主体属性、动作与字段判断「能不能」 +pub mod policy; /// 数据仓库层 pub mod repositories; /// 服务注册器 diff --git a/crates/biliup-cli/src/server/infrastructure/permissions.rs b/crates/biliup-cli/src/server/infrastructure/permissions.rs index 08fa7fc..5dcc9ab 100644 --- a/crates/biliup-cli/src/server/infrastructure/permissions.rs +++ b/crates/biliup-cli/src/server/infrastructure/permissions.rs @@ -1,8 +1,7 @@ //! Web 用户的固定三角色与权限点(#1717)。 //! //! 角色只是权限点的集合;路由按权限点声明所需权限(见 [`required_permission`]), -//! 没有声明的路由只允许超管访问。以后要开放自定义勾选,只需让用户自带一组权限点, -//! 不用再动路由表。 +//! 没有声明的路由只允许超管访问。「能不能」的判断统一在 [`super::policy`],这里只是数据。 use axum::http::Method; use serde::{Deserialize, Serialize}; @@ -172,20 +171,15 @@ pub fn required_permission(method: &Method, route: &str, raw_path: &str) -> Opti FileView } } - "/v1/web-users" | "/v1/web-users/{id}" | "/v1/web-users/{id}/logout-all" => UserManage, + "/v1/web-users" + | "/v1/web-users/roles" + | "/v1/web-users/{id}" + | "/v1/web-users/{id}/logout-all" => UserManage, _ => return None, }; Some(permission) } -/// 该角色能否访问这条路由。超管不受路由表限制。 -pub fn route_allowed(role: Role, method: &Method, route: &str, raw_path: &str) -> bool { - if role == Role::Admin { - return true; - } - required_permission(method, route, raw_path).is_some_and(|permission| role.has(permission)) -} - #[cfg(test)] mod tests { use super::*; @@ -264,36 +258,6 @@ mod tests { } } - #[test] - fn unknown_routes_and_methods_are_admin_only() { - for role in [Role::Operator, Role::Viewer] { - assert!(!route_allowed( - role, - &Method::GET, - "/v1/new-feature", - "/v1/new-feature" - )); - assert!(!route_allowed( - role, - &Method::PATCH, - "/v1/streamers", - "/v1/streamers" - )); - assert!(!route_allowed( - role, - &Method::DELETE, - "/v1/status", - "/v1/status" - )); - } - assert!(route_allowed( - Role::Admin, - &Method::GET, - "/v1/new-feature", - "/v1/new-feature" - )); - } - #[test] fn static_route_distinguishes_logs_from_recordings() { assert_eq!( @@ -306,12 +270,59 @@ mod tests { ); } + /// `export type = 'a' | 'b'` 里的字符串字面量;单行或每行一个 `| 'x'` 都行。 + fn ts_union(source: &str, name: &str) -> Vec { + let head = format!("export type {name} ="); + let start = source.find(&head).expect("前端类型定义不见了") + head.len(); + let mut lines = source[start..].lines(); + let mut union = lines.next().unwrap_or_default().to_string(); + for line in lines { + if !line.trim_start().starts_with('|') { + break; + } + union.push_str(line); + } + union + .split('|') + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(|item| { + item.strip_prefix('\'') + .and_then(|rest| rest.strip_suffix('\'')) + .unwrap_or_else(|| panic!("{name} 里有非字符串字面量:{item}")) + .to_string() + }) + .collect() + } + + fn serde_names(items: impl IntoIterator) -> Vec { + items + .into_iter() + .map(|item| { + serde_json::to_value(item) + .unwrap() + .as_str() + .unwrap() + .to_string() + }) + .collect() + } + + /// 前端 `app/lib/use-me.ts` 手写了权限点与角色的字符串联合类型,两边必须一一对应: + /// 少了前端就判断不到新权限点,多了就是前端在判断一个后端从不下发的权限。 #[test] - fn permission_names_match_the_frontend_contract() { - assert_eq!( - serde_json::to_value(Permission::StreamerHooks).unwrap(), - "streamer.hooks" - ); - assert_eq!(serde_json::to_value(Role::Operator).unwrap(), "operator"); + fn names_match_the_frontend_contract() { + let source = include_str!("../../../../../app/lib/use-me.ts"); + let mut frontend = ts_union(source, "Permission"); + let mut backend = serde_names(Permission::ALL.iter().copied()); + frontend.sort(); + backend.sort(); + assert_eq!(frontend, backend, "use-me.ts 的 Permission 与后端不一致"); + + let mut frontend = ts_union(source, "Role"); + let mut backend = serde_names(Role::ALL); + frontend.sort(); + backend.sort(); + assert_eq!(frontend, backend, "use-me.ts 的 Role 与后端不一致"); } } diff --git a/crates/biliup-cli/src/server/infrastructure/policy.rs b/crates/biliup-cli/src/server/infrastructure/policy.rs new file mode 100644 index 0000000..79c0e59 --- /dev/null +++ b/crates/biliup-cli/src/server/infrastructure/policy.rs @@ -0,0 +1,182 @@ +//! 授权决策点:所有「能不能」都在这里回答。 +//! +//! 决策只看属性:主体属性([`Subject`]:角色,以及环境属性 `--auth` 是否开启)、 +//! 动作([`Permission`])、路由([`RouteRequirement`])和字段([`Field`])。 +//! 访问控制中间件、处理函数里的字段保护与脱敏、长连接复查、`/v1/me` 下发的权限点都只调这里, +//! 不再各自拿 `Role` 判断。以后加新的主体属性或资源属性(例如按主播分配),只改这一处。 + +use crate::server::infrastructure::permissions::{Permission, Role, required_permission}; +use axum::http::Method; + +/// 发起请求的主体。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Subject { + /// `--auth` 关闭时没有登录用户,为 `None` + pub user_id: Option, + pub role: Role, + /// 环境属性:`--auth` 是否开启。关闭时零鉴权、视为超管,但没有登录用户可管。 + pub auth_enabled: bool, +} + +impl Subject { + /// `--auth` 开启时的登录用户。 + pub fn user(id: i64, role: Role) -> Self { + Subject { + user_id: Some(id), + role, + auth_enabled: true, + } + } + + /// `--auth` 关闭时:零鉴权,视为超管。 + pub fn unrestricted() -> Self { + Subject { + user_id: None, + role: Role::Admin, + auth_enabled: false, + } + } + + pub fn can(&self, action: Permission) -> bool { + match action { + // 零鉴权模式下没有登录用户,也就没有用户可管 + Permission::UserManage if !self.auth_enabled => false, + _ => self.role.has(action), + } + } + + /// 实际生效的全部权限点,`/v1/me` 原样下发给前端。 + pub fn permissions(&self) -> Vec { + Permission::ALL + .iter() + .copied() + .filter(|permission| self.can(*permission)) + .collect() + } + + pub fn satisfies(&self, requirement: RouteRequirement) -> bool { + match requirement { + RouteRequirement::Permission(permission) => self.can(permission), + RouteRequirement::AdminOnly => self.role == Role::Admin, + } + } + + /// 字段的读写是同一条规则:看不到的字段,保存时也以库里原值为准(见各 [`Field`] 的说明)。 + pub fn can_access(&self, field: Field) -> bool { + self.can(field.guard()) + } +} + +/// 一条路由对主体的要求。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RouteRequirement { + Permission(Permission), + /// 路由表里没有登记:只允许超管(默认拒绝) + AdminOnly, +} + +impl RouteRequirement { + pub fn of(method: &Method, route: &str, raw_path: &str) -> Self { + required_permission(method, route, raw_path) + .map_or(RouteRequirement::AdminOnly, RouteRequirement::Permission) + } +} + +/// 路由之内按字段区分的数据:有权限的人原样读写,没有的人读到的是脱敏版、写不进去。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Field { + /// 主播与全局配置里的 `*processor` 钩子和 `override`:钩子走 `sh -c`,等于服务器 shell + StreamerHooks, + /// 全局配置里白名单之外的字段(凭据、路径形式的凭据、钩子),见 `redact::config` + ConfigSecrets, + /// 投稿模板绑定的 B 站账号(`user_cookie`):没有权限的人只能从已登记的账号里选,状态接口里也看不到 + TemplateAccount, + /// 投稿模板的封面路径:会被原样读出来上传,界面上不提供编辑,没有权限的人保存时保留原值 + TemplateCoverPath, +} + +impl Field { + fn guard(self) -> Permission { + match self { + Field::StreamerHooks => Permission::StreamerHooks, + Field::ConfigSecrets => Permission::ConfigEdit, + Field::TemplateAccount | Field::TemplateCoverPath => Permission::AccountManage, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const FIELDS: [Field; 4] = [ + Field::StreamerHooks, + Field::ConfigSecrets, + Field::TemplateAccount, + Field::TemplateCoverPath, + ]; + + #[test] + fn unknown_routes_and_methods_are_admin_only() { + let requirement = |method: Method, path: &str| RouteRequirement::of(&method, path, path); + for role in [Role::Operator, Role::Viewer] { + let subject = Subject::user(1, role); + assert!(!subject.satisfies(requirement(Method::GET, "/v1/new-feature"))); + assert!(!subject.satisfies(requirement(Method::PATCH, "/v1/streamers"))); + assert!(!subject.satisfies(requirement(Method::DELETE, "/v1/status"))); + } + assert_eq!( + requirement(Method::GET, "/v1/new-feature"), + RouteRequirement::AdminOnly + ); + assert!(Subject::user(1, Role::Admin).satisfies(RouteRequirement::AdminOnly)); + } + + #[test] + fn user_management_needs_auth() { + let unrestricted = Subject::unrestricted(); + assert!(!unrestricted.can(Permission::UserManage)); + assert!(!unrestricted.permissions().contains(&Permission::UserManage)); + // 其它权限点照旧全部拥有 + assert_eq!(unrestricted.permissions().len(), Permission::ALL.len() - 1); + assert!(Subject::user(1, Role::Admin).can(Permission::UserManage)); + } + + #[test] + fn permissions_list_matches_can() { + for role in Role::ALL { + for subject in [ + Subject::user(1, role), + Subject { + auth_enabled: false, + ..Subject::user(1, role) + }, + ] { + let listed = subject.permissions(); + for permission in Permission::ALL { + assert_eq!( + listed.contains(permission), + subject.can(*permission), + "{subject:?} {permission:?}" + ); + } + } + } + } + + #[test] + fn protected_fields_follow_the_approved_matrix() { + for field in FIELDS { + assert!(Subject::user(1, Role::Admin).can_access(field), "{field:?}"); + assert!(Subject::unrestricted().can_access(field), "{field:?}"); + assert!( + !Subject::user(1, Role::Operator).can_access(field), + "{field:?}" + ); + assert!( + !Subject::user(1, Role::Viewer).can_access(field), + "{field:?}" + ); + } + } +} diff --git a/crates/biliup-cli/src/server/infrastructure/users.rs b/crates/biliup-cli/src/server/infrastructure/users.rs index 33642f3..2d53ed4 100644 --- a/crates/biliup-cli/src/server/infrastructure/users.rs +++ b/crates/biliup-cli/src/server/infrastructure/users.rs @@ -1,10 +1,10 @@ use crate::server::infrastructure::connection_pool::ConnectionPool; -use crate::server::infrastructure::permissions::{Permission, Role}; -use axum_login::{AuthUser, AuthnBackend, AuthzBackend, UserId}; +use crate::server::infrastructure::permissions::Role; +use axum_login::{AuthUser, AuthnBackend, UserId}; use password_auth::{generate_hash, verify_password}; use serde::{Deserialize, Serialize}; use sqlx::FromRow; -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::str::FromStr; use std::sync::{Arc, Mutex, OnceLock}; use std::time::{Duration, Instant}; @@ -604,17 +604,6 @@ impl AuthnBackend for Backend { } } -impl AuthzBackend for Backend { - type Permission = Permission; - - async fn get_user_permissions( - &self, - user: &Self::User, - ) -> Result, Self::Error> { - Ok(user.role.permissions().iter().copied().collect()) - } -} - pub type AuthSession = axum_login::AuthSession; #[cfg(test)]