Files
biliup/crates/biliup-cli/src/server/infrastructure/permissions.rs
T
ForgQi dd7bb7a9ee 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.
2026-09-24 19:44:35 +08:00

329 lines
12 KiB
Rust

//! Web 用户的固定三角色与权限点(#1717)。
//!
//! 角色只是权限点的集合;路由按权限点声明所需权限(见 [`required_permission`]),
//! 没有声明的路由只允许超管访问。「能不能」的判断统一在 [`super::policy`],这里只是数据。
use axum::http::Method;
use serde::{Deserialize, Serialize};
use std::fmt;
use std::str::FromStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Role {
Admin,
Operator,
Viewer,
}
impl Role {
pub const ALL: [Role; 3] = [Role::Admin, Role::Operator, Role::Viewer];
pub fn as_str(self) -> &'static str {
match self {
Role::Admin => "admin",
Role::Operator => "operator",
Role::Viewer => "viewer",
}
}
pub fn permissions(self) -> &'static [Permission] {
use Permission::*;
match self {
Role::Admin => Permission::ALL,
Role::Operator => &[
StreamerView,
PreviewView,
RecordingControl,
StreamerEdit,
UploadSubmit,
TemplateEdit,
ConfigView,
LogView,
FileView,
],
Role::Viewer => &[StreamerView, PreviewView, ConfigView, LogView, FileView],
}
}
pub fn has(self, permission: Permission) -> bool {
self.permissions().contains(&permission)
}
}
impl fmt::Display for Role {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for Role {
type Err = ();
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"admin" => Ok(Role::Admin),
"operator" => Ok(Role::Operator),
"viewer" => Ok(Role::Viewer),
_ => Err(()),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Permission {
#[serde(rename = "streamer.view")]
StreamerView,
#[serde(rename = "preview.view")]
PreviewView,
#[serde(rename = "recording.control")]
RecordingControl,
#[serde(rename = "streamer.edit")]
StreamerEdit,
/// 主播与全局配置里的 `*processor` 钩子和 `override`:钩子走 `sh -c`,等于服务器 shell
#[serde(rename = "streamer.hooks")]
StreamerHooks,
#[serde(rename = "upload.submit")]
UploadSubmit,
#[serde(rename = "template.edit")]
TemplateEdit,
#[serde(rename = "account.manage")]
AccountManage,
/// 非超管看到的是脱敏版,见 `redact`
#[serde(rename = "config.view")]
ConfigView,
#[serde(rename = "config.edit")]
ConfigEdit,
#[serde(rename = "log.view")]
LogView,
#[serde(rename = "file.view")]
FileView,
#[serde(rename = "user.manage")]
UserManage,
}
impl Permission {
pub const ALL: &'static [Permission] = &[
Permission::StreamerView,
Permission::PreviewView,
Permission::RecordingControl,
Permission::StreamerEdit,
Permission::StreamerHooks,
Permission::UploadSubmit,
Permission::TemplateEdit,
Permission::AccountManage,
Permission::ConfigView,
Permission::ConfigEdit,
Permission::LogView,
Permission::FileView,
Permission::UserManage,
];
}
/// 路由(axum 的路径模板)→ 所需权限点。
///
/// 返回 `None` 表示没有声明,只允许超管访问(默认拒绝)。新增路由必须在这里登记,
/// `every_registered_route_is_classified` 测试会扫描 `router.rs` / `app.rs` 保证这一点。
pub fn required_permission(method: &Method, route: &str, raw_path: &str) -> Option<Permission> {
use Permission::*;
let get = method == Method::GET || method == Method::HEAD;
let permission = match route {
"/v1/streamers" if get => StreamerView,
"/v1/streamers" if method == Method::POST || method == Method::PUT => StreamerEdit,
"/v1/streamers/{id}" if method == Method::DELETE => StreamerEdit,
"/v1/streamers/{id}/pause" if method == Method::PUT => RecordingControl,
"/v1/streamers/{id}/cover" | "/v1/streamers/{id}/avatar" if get => StreamerView,
"/v1/streamers/{id}/live"
| "/v1/streamers/{id}/live-url"
| "/v1/streamers/{id}/danmaku"
| "/v1/danmaku"
if get =>
{
PreviewView
}
"/v1/ws/live-rates" | "/v1/live-rates" if get => StreamerView,
"/v1/configuration" if get => ConfigView,
"/v1/configuration" if method == Method::PUT => ConfigEdit,
"/v1/streamer-info" | "/v1/streamer-info/files/{id}" if get => StreamerView,
"/v1/upload/streamers" | "/v1/upload/streamers/{id}" if get => StreamerView,
"/v1/upload/streamers" if method == Method::POST => TemplateEdit,
"/v1/upload/streamers/{id}" if method == Method::DELETE => TemplateEdit,
// 编辑投稿模板时要从已登记的 B 站账号里选一个,所以列表与资料归模板编辑;
// 新增、删除账号与扫码登录归账号管理。
"/v1/users" | "/v1/users/{id}" if get => TemplateEdit,
"/v1/users" if method == Method::POST => AccountManage,
"/v1/users/{id}" if method == Method::DELETE => AccountManage,
"/v1/users/{id}/archives" if get => AccountManage,
"/v1/get_qrcode" if get => AccountManage,
"/v1/login_by_qrcode" if method == Method::POST => AccountManage,
"/bili/archive/pre" if get => UploadSubmit,
"/v1/uploads" if method == Method::POST => UploadSubmit,
"/v1/videos" if get => FileView,
"/v1/status" if get => StreamerView,
// 三个角色都有 StreamerView,等于登录即可
"/v1/tools" if get => StreamerView,
"/v1/ws/logs" if get => LogView,
"/static/{path}" if get => {
let name = raw_path.rsplit('/').next().unwrap_or_default();
if crate::server::router::ALLOWED_LOG_FILES.contains(&name) {
LogView
} else {
FileView
}
}
"/v1/web-users"
| "/v1/web-users/roles"
| "/v1/web-users/{id}"
| "/v1/web-users/{id}/logout-all" => UserManage,
_ => return None,
};
Some(permission)
}
#[cfg(test)]
mod tests {
use super::*;
/// `.route("<path>"` 里的路径字面量,不管中间有没有换行。
fn registered_routes(source: &str) -> Vec<String> {
let mut routes = Vec::new();
let mut rest = source;
while let Some(index) = rest.find(".route(") {
rest = &rest[index + ".route(".len()..];
let Some(open) = rest.find('"') else { break };
let after = &rest[open + 1..];
let Some(close) = after.find('"') else { break };
routes.push(after[..close].to_string());
rest = &after[close + 1..];
}
routes
}
fn non_test_source(source: &str) -> &str {
source.split("#[cfg(test)]").next().unwrap()
}
#[test]
fn every_registered_route_is_classified() {
let sources = [
include_str!("../router.rs"),
include_str!("../app.rs"),
include_str!("../api/web_users.rs"),
];
// 这些路由有意不挂权限层:登录相关对外公开,`/v1/me*` 只要求登录。
let unguarded = [
"/v1/users/login",
"/v1/users/register",
"/v1/users/biliup",
"/v1/logout",
"/v1/me",
"/v1/me/password",
];
let methods = [Method::GET, Method::POST, Method::PUT, Method::DELETE];
let mut seen = 0;
for source in sources {
for route in registered_routes(non_test_source(source)) {
seen += 1;
if unguarded.contains(&route.as_str()) {
continue;
}
assert!(
methods
.iter()
.any(|method| required_permission(method, &route, &route).is_some()),
"路由 {route} 没有登记权限点,非超管会被一律拒绝;请在 required_permission 里登记"
);
}
}
assert!(seen >= 30, "只扫到 {seen} 条路由,扫描逻辑可能失效");
}
#[test]
fn roles_follow_the_approved_matrix() {
use Permission::*;
for permission in Permission::ALL {
assert!(Role::Admin.has(*permission));
}
for permission in [StreamerHooks, AccountManage, ConfigEdit, UserManage] {
assert!(!Role::Operator.has(permission), "{permission:?}");
assert!(!Role::Viewer.has(permission), "{permission:?}");
}
for permission in [RecordingControl, StreamerEdit, UploadSubmit, TemplateEdit] {
assert!(Role::Operator.has(permission), "{permission:?}");
assert!(!Role::Viewer.has(permission), "{permission:?}");
}
for permission in [StreamerView, PreviewView, ConfigView, LogView, FileView] {
assert!(Role::Operator.has(permission), "{permission:?}");
assert!(Role::Viewer.has(permission), "{permission:?}");
}
}
#[test]
fn static_route_distinguishes_logs_from_recordings() {
assert_eq!(
required_permission(&Method::GET, "/static/{path}", "/static/ds_update.log"),
Some(Permission::LogView)
);
assert_eq!(
required_permission(&Method::GET, "/static/{path}", "/static/a.flv"),
Some(Permission::FileView)
);
}
/// `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 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 与后端不一致");
}
}