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.
This commit is contained in:
ForgQi committed 2026-09-24 19:44:35 +08:00
1 parent e4fb9c01df
commit dd7bb7a9ee
19 files changed
+798 -198

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
# sqlx 对迁移文件的原始字节求 SHA-384 并记进 _sqlx_migrations。同一份 SQL 在
# core.autocrlf=true 的 Windows(含 GitHub windows-latest 上的发布构建)会被检出成 CRLF,
# 编出来的二进制就内嵌另一套摘要。一律检出成 LF,各平台的构建才记同一套校验和。
*.sql text eol=lf
-1
View File
@@ -1,6 +1,5 @@
.idea
.vscode
.gitattributes
*.pyc
*.flv
*.mp4
+5 -9
View File
@@ -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 () => {
+5
View File
@@ -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])
+48 -15
View File
@@ -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<Role, 'red' | 'blue' | 'grey'> = {
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 }) => (
<Form.Radio key={role} value={role} className={styles.roleOption}>
<span className={styles.roleName}>{ROLE_LABELS[role]}</span>
<span className={styles.roleDesc}>{ROLE_DESCRIPTIONS[role]}</span>
<span className={styles.roleDesc}>{describe(permissions)}</span>
</Form.Radio>
))}
</>
@@ -95,10 +110,12 @@ type DialogValues = { username?: string; password?: string; role?: Role }
function UserDialog({
dialog,
roles,
onClose,
onDone,
}: {
dialog: Exclude<Dialog, null>
roles: RoleInfo[]
onClose: () => void
onDone: () => void
}) {
@@ -191,7 +208,7 @@ function UserDialog({
)}
{dialog.kind !== 'password' && (
<Form.RadioGroup field="role" label="角色" direction="vertical" rules={[{ required: true }]}>
<RoleOptions />
<RoleOptions roles={roles} />
</Form.RadioGroup>
)}
</Form>
@@ -205,10 +222,16 @@ export default function UsersPage() {
// 六列表格放在侧栏旁边至少要 960 左右的视口;更窄时改成卡片,操作按钮不会被裁掉
const compact = useIsMobile(960)
const { data: users, error, isLoading, mutate } = useSWR<WebUser[]>(USERS_KEY, fetcher)
const { data: roles, error: rolesError, isLoading: rolesLoading, mutate: mutateRoles } = useSWR<RoleInfo[]>(
ROLES_KEY,
fetcher,
{ revalidateOnFocus: false },
)
const [dialog, setDialog] = useState<Dialog>(null)
const refresh = () => {
mutate().catch(() => undefined)
if (rolesError) mutateRoles().catch(() => undefined)
}
const run = async (action: () => Promise<unknown>, success: string) => {
@@ -318,16 +341,19 @@ export default function UsersPage() {
]
let body
if (isLoading) {
if (isLoading || rolesLoading) {
body = (
<div className={styles.center}>
<Spin size="large" />
</div>
)
} else if (error) {
} else if (error || rolesError || !roles) {
body = (
<div className={styles.center}>
<Empty title="加载失败" description={(error as Error).message || '无法获取用户列表'} />
<Empty
title="加载失败"
description={((error || rolesError) as Error | undefined)?.message || '无法获取用户列表'}
/>
<Button onClick={refresh} style={{ marginTop: 12 }}>
重试
</Button>
@@ -375,7 +401,12 @@ export default function UsersPage() {
title="用户管理"
description="为其他人开设账号并分配角色。改动即时生效,至少保留一个启用中的超级管理员"
actions={
<Button icon={<IconPlusCircle />} theme="solid" onClick={() => setDialog({ kind: 'create' })}>
<Button
icon={<IconPlusCircle />}
theme="solid"
disabled={!roles}
onClick={() => setDialog({ kind: 'create' })}
>
{isMobile ? '新建' : '新建用户'}
</Button>
}
@@ -393,17 +424,19 @@ export default function UsersPage() {
)}
{body}
<div className={styles.legend}>
{ROLES.map((role) => (
{roles?.map(({ role, permissions }) => (
<div key={role} className={styles.legendItem}>
{roleCell({ role } as WebUser)}
<Text type="tertiary" size="small">
{ROLE_DESCRIPTIONS[role]}
{describe(permissions)}
</Text>
</div>
))}
</div>
</div>
{dialog && <UserDialog dialog={dialog} onClose={() => setDialog(null)} onDone={refresh} />}
{dialog && roles && (
<UserDialog dialog={dialog} roles={roles} onClose={() => setDialog(null)} onDone={refresh} />
)}
</>
)
}
+17 -2
View File
@@ -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<T>(url: string, { arg }: { arg: T }) {
const res = await fetch(API_BASE + url, {
method: 'POST',
@@ -40,7 +52,8 @@ export async function put<T>(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 {
+6 -1
View File
@@ -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
}
}
+6 -3
View File
@@ -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')
+27 -9
View File
@@ -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<Role, string> = {
@@ -25,10 +31,21 @@ export const ROLE_LABELS: Record<Role, string> = {
viewer: '只读观察者',
}
export const ROLE_DESCRIPTIONS: Record<Role, string> = {
admin: '全部权限,包括用户管理、全局配置、B 站账号与后处理命令',
operator: '管理直播间、控制录制、编辑投稿模板和手动投稿',
viewer: '只能查看录制状态、预览、日志和录播文件',
/** 权限点的显示名。角色拥有哪些权限点由后端下发(`/v1/web-users/roles`),前端不再手写。 */
export const PERMISSION_LABELS: Record<Permission, string> = {
'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>(ME_KEY, fetcher, {
revalidateOnFocus: false,
revalidateOnFocus: true,
dedupingInterval: 10_000,
})
const permissions = data?.permissions
+3 -1
View File
@@ -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} 次`)
+29
View File
@@ -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()
);
}
}
+25 -25
View File
@@ -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<i64>,
pub role: Role,
pub subject: Subject,
watch: Option<SessionWatch>,
}
@@ -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<u8>,
permission: Option<Permission>,
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::<MatchedPath>()
.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 {
+105 -19
View File
@@ -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<ConnectionPool>,
Json(mut payload): Json<InsertLiveStreamer>,
) -> Result<Json<LiveStreamer>, 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<ConnectionPool>,
Json(mut payload): Json<LiveStreamer>,
) -> Result<Json<LiveStreamer>, 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<Arc<RwLock<Config>>>,
) -> Result<Json<serde_json::Value>, 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<ConnectionPool>,
Json(mut upload_streamer): Json<InsertUploadStreamer>,
) -> Result<Json<serde_json::Value>, 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<i64>, 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<i64>,
State(pool): State<ConnectionPool>,
@@ -732,7 +815,8 @@ pub async fn get_status(
) -> Result<Json<serde_json::Value>, 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())
+80 -9
View File
@@ -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<i64>, 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::<Vec<_>>()).into_response(),
@@ -140,7 +160,9 @@ async fn update_user(
Path(id): Path<i64>,
Json(changes): Json<UserChanges>,
) -> 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<i64>) -> 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<i64>) -> 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;
@@ -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<Sqlite>;
/// 一条曾经被就地改写过的迁移。
///
/// `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<u8>, 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<u8>)> = 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<u8> {
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<u8> {
(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<u8>)> =
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::<Vec<_>>(),
embedded
.iter()
.map(|m| (m.version, hex_lower(&m.checksum)))
.collect::<Vec<_>>(),
);
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() {
@@ -8,6 +8,8 @@ pub mod dto;
pub mod models;
/// Web 用户的角色与权限点
pub mod permissions;
/// 授权决策点:按主体属性、动作与字段判断「能不能」
pub mod policy;
/// 数据仓库层
pub mod repositories;
/// 服务注册器
@@ -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 <name> = 'a' | 'b'` 里的字符串字面量;单行或每行一个 `| 'x'` 都行。
fn ts_union(source: &str, name: &str) -> Vec<String> {
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<T: Serialize>(items: impl IntoIterator<Item = T>) -> Vec<String> {
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 与后端不一致");
}
}
@@ -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<i64>,
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> {
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:?}"
);
}
}
}
@@ -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<HashSet<Self::Permission>, Self::Error> {
Ok(user.role.permissions().iter().copied().collect())
}
}
pub type AuthSession = axum_login::AuthSession<Backend>;
#[cfg(test)]