Artifact e7a9417405b9cd1d448c5b64631c97cc6adde28dc3877e337555586b797e366d:
- File
backend/src/access.gleam
— part of check-in
[1821522f51]
at
2026-10-23 05:51:23
on branch trunk
— RBAC 权限与多仓数据隔离
- 新增用户、角色、权限点、用户角色、用户授权仓库、登录会话、审计日志表(008_rbac) - 预设五个角色(超级管理员、仓库主管、制单/调度员、打包复核员、库内作业员),按角色限制登录终端 - 账号密码登录,PBKDF2-SHA256 加盐存储,令牌只存哈希,会话按配置过期,连续输错锁定 - 首个超级管理员从 SWIFTFLOW_ADMIN_USERNAME / SWIFTFLOW_ADMIN_PASSWORD 创建,仅在用户表为空时生效 - 除健康检查与登录外所有接口需登录并校验权限点 - 订单、采购单、库位、库存、批次码按授权仓库过滤;收货、上架、出库只能在当前作业仓库进行 - 用户管理、角色查询、审计日志接口;同步、库位、上架、收货、出库写操作记录审计 (user: pluto size: 16526)
import clock import config.{type AuthConfig} import database.{type Database, type DbError} import gleam/int import gleam/list import gleam/option.{None, Some} import gleam/order import gleam/result import gleam/string import logging import password import rbac.{type Scope} import repo import user_repo.{type User, AuditEntry} pub type Principal { Principal( user_id: String, username: String, display_name: String, roles: List(String), permissions: List(String), warehouse_ids: List(Int), scope: Scope, terminal: String, current_warehouse: Int, token_hash: String, expires_at: String, ) } pub type AuthError { Unauthorized(String) Forbidden(String) Rejected(String) NotFound(String) AuthDb(DbError) } pub type Bootstrap { AdminCreated(username: String) UsersExist AdminNotConfigured } pub type UserInput { UserInput( display_name: String, enabled: Bool, roles: List(String), warehouse_ids: List(Int), ) } pub fn has(principal: Principal, permission: String) -> Bool { list.contains(principal.permissions, permission) } pub fn bootstrap_admin(db: Database, cfg: AuthConfig) -> Result(Bootstrap, String) { case database.transaction(db, user_repo.count_users) { Error(database.DbError(message)) -> Error(message) Ok(n) if n > 0 -> Ok(UsersExist) Ok(_) -> case normalize_username(cfg.admin_username), cfg.admin_password { Error(_), _ | _, "" -> Ok(AdminNotConfigured) Ok(username), secret -> case password.validate(secret) { Error(message) -> Error("SWIFTFLOW_ADMIN_PASSWORD 不合格:" <> message) Ok(_) -> { let now = clock.now_local() let hash = password.hash(secret) case database.transaction(db, fn(conn) { let id = new_user_id() use _ <- result.try(user_repo.insert_user( conn, id, username, "超级管理员", hash, now, )) user_repo.set_roles(conn, id, [rbac.sys_admin]) }) { Ok(_) -> Ok(AdminCreated(username)) Error(database.DbError(message)) -> Error(message) } } } } } } pub fn login( db: Database, cfg: AuthConfig, raw_username: String, secret: String, terminal: String, ) -> Result(#(String, Principal), AuthError) { let terminal = string.lowercase(string.trim(terminal)) use <- guard( !list.contains(rbac.terminals, terminal), Rejected("terminal 只能是 " <> string.join(rbac.terminals, "、")), ) let username = result.unwrap(normalize_username(raw_username), "") use found <- result.try(db_call(db, fn(conn) { user_repo.find_credential(conn, username) })) let now = clock.now_local() case found { None -> { let _ = password.burn(secret) audit_raw(db, "", username, "auth.login_failed", username, 0, "账号不存在") Error(Unauthorized("账号或密码错误")) } Some(cred) -> case cred.locked_until != "" && string.compare(cred.locked_until, now) == order.Gt { True -> Error(Unauthorized("密码错误次数过多,账号锁定到 " <> cred.locked_until)) False -> case password.verify(secret, cred.password_hash) { False -> { let attempts = cred.failed_attempts + 1 let #(attempts, locked_until) = case attempts >= cfg.max_failed_logins { True -> #(0, clock.shift_seconds_local(cfg.lockout_minutes * 60)) False -> #(attempts, "") } use _ <- result.try(db_call(db, fn(conn) { user_repo.record_login_failure(conn, cred.id, attempts, locked_until, now) })) audit_raw(db, cred.id, cred.username, "auth.login_failed", cred.username, 0, "密码错误") case locked_until { "" -> Error(Unauthorized("账号或密码错误")) until -> Error(Unauthorized("密码错误次数过多,账号锁定到 " <> until)) } } True -> case cred.enabled { False -> Error(Forbidden("账号已停用")) True -> open_session(db, cfg, cred.id, terminal, now) } } } } } fn open_session( db: Database, cfg: AuthConfig, user_id: String, terminal: String, now: String, ) -> Result(#(String, Principal), AuthError) { use roles <- result.try(db_call(db, fn(conn) { use roles <- result.try(user_repo.user_roles(conn, user_id)) use defs <- result.try(user_repo.list_roles(conn)) Ok(#(roles, defs)) })) let #(role_codes, defs) = roles let allowed = list.any(defs, fn(def: user_repo.RoleRow) { list.contains(role_codes, def.code) && list.contains(def.terminals, terminal) }) use <- guard(!allowed, Forbidden("当前角色不能从该终端登录")) let token = password.random_hex(32) let token_hash = password.sha256_hex(token) let expires_at = clock.shift_seconds_local(cfg.session_hours * 3600) use principal <- result.try(db_call(db, fn(conn) { use warehouses <- result.try(user_repo.user_warehouses(conn, user_id)) let current = case rbac.scope_for(role_codes, warehouses), warehouses { rbac.Warehouses(_), [only] -> only _, _ -> 0 } use _ <- result.try(user_repo.reset_login_failures(conn, user_id)) use _ <- result.try(user_repo.delete_expired_sessions(conn, now)) use _ <- result.try(user_repo.insert_session( conn, token_hash, user_id, terminal, current, now, expires_at, )) load_principal(conn, user_id, terminal, current, token_hash, expires_at) })) audit(db, principal, "auth.login", principal.username, principal.current_warehouse, terminal) Ok(#(token, principal)) } pub fn authenticate(db: Database, token: String) -> Result(Principal, AuthError) { use <- guard(token == "", Unauthorized("请先登录")) let token_hash = password.sha256_hex(token) let now = clock.now_local() use found <- result.try(db_call(db, fn(conn) { use session <- result.try(user_repo.find_session(conn, token_hash, now)) case session { None -> Ok(None) Some(row) -> { use _ <- result.try(user_repo.touch_session(conn, token_hash, now)) use principal <- result.try(load_principal( conn, row.user_id, row.terminal, row.warehouse_id, token_hash, row.expires_at, )) Ok(Some(principal)) } } })) case found { None -> Error(Unauthorized("登录已失效,请重新登录")) Some(principal) -> Ok(principal) } } fn load_principal( conn: database.Conn, user_id: String, terminal: String, current: Int, token_hash: String, expires_at: String, ) -> Result(Principal, DbError) { use user <- result.try(user_repo.get_user(conn, user_id)) use permissions <- result.try(user_repo.user_permissions(conn, user_id)) let #(username, display_name, roles, warehouses) = case user { Some(u) -> #(u.username, u.display_name, u.roles, u.warehouse_ids) None -> #("", "", [], []) } let scope = rbac.scope_for(roles, warehouses) let current = case current > 0 && rbac.allows(scope, current) { True -> current False -> 0 } Ok(Principal( user_id: user_id, username: username, display_name: display_name, roles: roles, permissions: permissions, warehouse_ids: warehouses, scope: scope, terminal: terminal, current_warehouse: current, token_hash: token_hash, expires_at: expires_at, )) } pub fn logout(db: Database, principal: Principal) -> Result(Nil, AuthError) { use _ <- result.try(db_call(db, fn(conn) { user_repo.delete_session(conn, principal.token_hash) })) audit(db, principal, "auth.logout", principal.username, principal.current_warehouse, "") Ok(Nil) } pub fn select_warehouse( db: Database, principal: Principal, warehouse_id: Int, ) -> Result(Principal, AuthError) { use <- guard(!rbac.allows(principal.scope, warehouse_id), Forbidden("无权访问该仓库")) use found <- result.try(db_call(db, fn(conn) { repo.get_warehouse(conn, warehouse_id) })) case found { None -> Error(NotFound("仓库不存在")) Some(row) if !row.operational -> Error(Rejected("该仓库只作显示,不能作为作业仓库")) Some(_) -> { use _ <- result.try(db_call(db, fn(conn) { user_repo.set_session_warehouse(conn, principal.token_hash, warehouse_id) })) audit(db, principal, "auth.select_warehouse", int.to_string(warehouse_id), warehouse_id, "") Ok(Principal(..principal, current_warehouse: warehouse_id)) } } } pub fn change_password( db: Database, principal: Principal, old: String, new: String, ) -> Result(Nil, AuthError) { use stored <- result.try(db_call(db, fn(conn) { user_repo.password_hash_of(conn, principal.user_id) })) let ok = case stored { Some(hash) -> password.verify(old, hash) None -> False } use <- guard(!ok, Rejected("原密码不正确")) use new <- result.try(password.validate(new) |> result.map_error(Rejected)) let hash = password.hash(new) use _ <- result.try(db_call(db, fn(conn) { use _ <- result.try(user_repo.set_password(conn, principal.user_id, hash, clock.now_local())) user_repo.delete_user_sessions(conn, principal.user_id, principal.token_hash) })) audit(db, principal, "auth.change_password", principal.username, 0, "") Ok(Nil) } pub fn list_users(db: Database) -> Result(List(User), AuthError) { db_call(db, user_repo.list_users) } pub fn create_user( db: Database, actor: Principal, raw_username: String, secret: String, input: UserInput, ) -> Result(User, AuthError) { use username <- result.try(normalize_username(raw_username) |> result.map_error(Rejected)) use secret <- result.try(password.validate(secret) |> result.map_error(Rejected)) use input <- result.try(validate_input(db, input)) let hash = password.hash(secret) let now = clock.now_local() use outcome <- result.try(db_call(db, fn(conn) { use taken <- result.try(user_repo.username_taken(conn, username)) case taken { True -> Ok(Error("账号已存在")) False -> { let id = new_user_id() use _ <- result.try(user_repo.insert_user(conn, id, username, input.display_name, hash, now)) use _ <- result.try(user_repo.update_profile(conn, id, input.display_name, input.enabled, now)) use _ <- result.try(user_repo.set_roles(conn, id, input.roles)) use _ <- result.try(user_repo.set_warehouses(conn, id, input.warehouse_ids)) use user <- result.try(user_repo.get_user(conn, id)) Ok(option.to_result(user, "用户创建后读取失败")) } } })) use user <- result.try(result.map_error(outcome, Rejected)) audit(db, actor, "user.create", user.username, 0, describe_input(input)) Ok(user) } pub fn update_user( db: Database, actor: Principal, user_id: String, input: UserInput, ) -> Result(User, AuthError) { use input <- result.try(validate_input(db, input)) let is_self = user_id == actor.user_id use <- guard(is_self && !input.enabled, Rejected("不能停用自己的账号")) use <- guard( is_self && list.contains(actor.roles, rbac.sys_admin) && !list.contains(input.roles, rbac.sys_admin), Rejected("不能移除自己的超级管理员角色"), ) let now = clock.now_local() use found <- result.try(db_call(db, fn(conn) { use existing <- result.try(user_repo.get_user(conn, user_id)) case existing { None -> Ok(None) Some(_) -> { use _ <- result.try(user_repo.update_profile(conn, user_id, input.display_name, input.enabled, now)) use _ <- result.try(user_repo.set_roles(conn, user_id, input.roles)) use _ <- result.try(user_repo.set_warehouses(conn, user_id, input.warehouse_ids)) use _ <- result.try(case is_self { True -> Ok(Nil) False -> user_repo.delete_user_sessions(conn, user_id, "") }) user_repo.get_user(conn, user_id) } } })) case found { None -> Error(NotFound("用户不存在")) Some(user) -> { audit(db, actor, "user.update", user.username, 0, describe_input(input)) Ok(user) } } } pub fn reset_password( db: Database, actor: Principal, user_id: String, secret: String, ) -> Result(Nil, AuthError) { use secret <- result.try(password.validate(secret) |> result.map_error(Rejected)) let hash = password.hash(secret) let keep = case user_id == actor.user_id { True -> actor.token_hash False -> "" } use found <- result.try(db_call(db, fn(conn) { use existing <- result.try(user_repo.get_user(conn, user_id)) case existing { None -> Ok(None) Some(user) -> { use _ <- result.try(user_repo.set_password(conn, user_id, hash, clock.now_local())) use _ <- result.try(user_repo.delete_user_sessions(conn, user_id, keep)) Ok(Some(user)) } } })) case found { None -> Error(NotFound("用户不存在")) Some(user) -> { audit(db, actor, "user.reset_password", user.username, 0, "") Ok(Nil) } } } fn validate_input(db: Database, input: UserInput) -> Result(UserInput, AuthError) { let display_name = string.trim(input.display_name) let roles = list.unique(list.map(input.roles, string.trim)) let warehouse_ids = list.unique(input.warehouse_ids) use <- guard(display_name == "", Rejected("姓名不能为空")) use <- guard(string.length(display_name) > 32, Rejected("姓名不能超过 32 个字")) use <- guard(roles == [], Rejected("至少分配一个角色")) use known <- result.try(db_call(db, user_repo.list_roles)) let codes = list.map(known, fn(role: user_repo.RoleRow) { role.code }) use <- guard( !list.all(roles, fn(role) { list.contains(codes, role) }), Rejected("角色不存在"), ) use <- guard( !list.contains(roles, rbac.sys_admin) && warehouse_ids == [], Rejected("至少授权一个仓库"), ) use found <- result.try(db_call(db, fn(conn) { list.try_map(warehouse_ids, fn(id) { repo.get_warehouse(conn, id) }) })) use <- guard(list.any(found, option.is_none), Rejected("授权的仓库不存在")) Ok(UserInput( display_name: display_name, enabled: input.enabled, roles: roles, warehouse_ids: warehouse_ids, )) } fn describe_input(input: UserInput) -> String { "角色 " <> string.join(input.roles, ",") <> ";仓库 " <> string.join(list.map(input.warehouse_ids, int.to_string), ",") <> ";" <> case input.enabled { True -> "启用" False -> "停用" } } pub fn normalize_username(raw: String) -> Result(String, String) { let username = string.lowercase(string.trim(raw)) let length = string.length(username) let valid_chars = string.to_graphemes(username) |> list.all(fn(c) { string.contains("abcdefghijklmnopqrstuvwxyz0123456789_.-", c) }) case length >= 2 && length <= 32 && valid_chars { True -> Ok(username) False -> Error("账号为 2–32 位小写字母、数字或 _ . -") } } pub fn audit( db: Database, principal: Principal, action: String, target: String, warehouse_id: Int, detail: String, ) -> Nil { audit_raw(db, principal.user_id, principal.username, action, target, warehouse_id, detail) } fn audit_raw( db: Database, user_id: String, username: String, action: String, target: String, warehouse_id: Int, detail: String, ) -> Nil { let now = clock.now_local() let entry = AuditEntry( id: now <> "-" <> password.random_hex(4), user_id: user_id, username: username, action: action, target: target, warehouse_id: warehouse_id, detail: detail, created_at: now, ) case database.transaction(db, fn(conn) { user_repo.insert_audit(conn, entry) }) { Ok(_) -> Nil Error(database.DbError(message)) -> logging.log(logging.Error, "审计日志写入失败 " <> action <> ": " <> message) } } fn new_user_id() -> String { "U" <> password.random_hex(8) } fn db_call( db: Database, f: fn(database.Conn) -> Result(a, DbError), ) -> Result(a, AuthError) { database.transaction(db, f) |> result.map_error(AuthDb) } fn guard(condition: Bool, error: AuthError, next: fn() -> Result(a, AuthError)) -> Result(a, AuthError) { case condition { True -> Error(error) False -> next() } }