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:
1 parent
e4fb9c01df
commit
dd7bb7a9ee
19 files changed
+798
-198
No files matched your search
@@ -0,0 +1,4 @@
|
||||
# sqlx 对迁移文件的原始字节求 SHA-384 并记进 _sqlx_migrations。同一份 SQL 在
|
||||
# core.autocrlf=true 的 Windows(含 GitHub windows-latest 上的发布构建)会被检出成 CRLF,
|
||||
# 编出来的二进制就内嵌另一套摘要。一律检出成 LF,各平台的构建才记同一套校验和。
|
||||
*.sql text eol=lf
|
||||
@@ -1,6 +1,5 @@
|
||||
.idea
|
||||
.vscode
|
||||
.gitattributes
|
||||
*.pyc
|
||||
*.flv
|
||||
*.mp4
|
||||
|
||||
@@ -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,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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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} 次`)
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in new issue
Block a user