feat(auth): add opaque bearer sessions

This commit is contained in:
caoxiaozhu
2026-07-13 14:45:36 +08:00
parent 661990b27b
commit 653eda0596
59 changed files with 1408 additions and 408 deletions

View File

@@ -0,0 +1,8 @@
## 修复记录
- 14:43记录 bug 修复:客户端身份头可伪造且已初始化系统仍存在匿名重配置入口。
- Git 提交检查:已执行 `git fetch --all --prune`;未发现 upstream 新提交,本地 ahead 1 个提交 `661990b2 feat(expenses): add transactional expense case events`,它是本任务上一切片的费用事件事务提交,当前认证改造继续建立在该提交之上。
- 修改:新增 `AuthSession` 不透明 Bearer 会话和摘要存储,后端从服务端会话及员工表解析身份/角色;移除生产 `X-Auth-*` 授权来源,保护 Bootstrap、Settings、模型连通性、缓存、员工、分析、Agent、风险观测、审计及日志接口前端统一 Bearer、`sessionStorage`、集中 `401` 和原子登出Vite Setup 桥在初始化完成后锁定重配置。
- 操作:新增 `20260713_0002_auth_sessions.py` 并只生成 upgrade/downgrade 离线 SQL没有对持久化数据库执行迁移或重启领域回归测试使用仅存在于测试目录的身份依赖覆盖生产代码没有兼容伪造头。测试过程中发现规则初始化用例会重写 5 份 Excel 工件,已停止继续运行该类测试并保持这些文件不进入本次提交。
- 验证:容器 `x-financial-local-linux` 内认证/Bootstrap/费用事件/OpenAPI 21 项通过,受保护端点 13 项通过,全量收集 805 项成功;前端 17 项通过,生产构建成功;认证安全核心文件 Ruff 通过,`git diff --check` 通过,生产源码未检出 `X-Auth-*`Alembic 离线升级包含 `CREATE TABLE auth_sessions`,离线降级包含 `DROP TABLE auth_sessions`
- 影响:用户登录后必须使用服务端签发的短期 Bearer 会话,伪造用户名、角色或管理员头不再生效;业务经理不能冒充平台管理员,已初始化系统的基础设施信息与重配置入口不再向匿名请求开放。租户级查询守卫、会话清理/全部退出、登录限流和 SSO 仍按 P0 后续任务推进。

View File

@@ -258,6 +258,9 @@
- 登录后签发服务端可验证会话或 JWT服务端从会话和数据库解析用户、租户、角色和数据范围。 - 登录后签发服务端可验证会话或 JWT服务端从会话和数据库解析用户、租户、角色和数据范围。
- 移除客户端 `X-Auth-*` 作为授权事实来源管理面、Bootstrap、设置和模型连通性接口必须受平台管理员保护。 - 移除客户端 `X-Auth-*` 作为授权事实来源管理面、Bootstrap、设置和模型连通性接口必须受平台管理员保护。
- P0 采用不透明 Bearer 会话:明文 token 仅在登录成功时返回,数据库只保存 SHA-256 摘要;每次请求从服务端会话和员工数据重新解析当前身份与角色,过期、撤销或不存在的 token 统一拒绝。
- Web 端只在 `sessionStorage` 保存 token 和过期时间,普通请求、流式请求及页面关闭收尾统一携带 Bearer任一 `401` 触发本地会话清理,登出时会话指标与 token 撤销在同一事务完成。
- 已初始化系统的 Bootstrap 状态只返回脱敏信息Bootstrap 写入、系统设置、模型连通性、缓存、审计与系统日志等敏感管理面由平台管理员权限保护Vite 本地 Setup 桥在初始化完成后锁定重新配置入口。
- P0 数据契约即引入最小 `tenant_id`、数据库约束、行级过滤、向量库命名空间和对象存储前缀隔离;删除传播、数据导出和私有部署加固可在商业化阶段继续完善。 - P0 数据契约即引入最小 `tenant_id`、数据库约束、行级过滤、向量库命名空间和对象存储前缀隔离;删除传播、数据导出和私有部署加固可在商业化阶段继续完善。
- 自动化权限按动作、金额、场景、风险和有效期授予,不使用全局“允许 Agent 自动执行”开关。 - 自动化权限按动作、金额、场景、风险和有效期授予,不使用全局“允许 Agent 自动执行”开关。
- 收款账户变更、资金支付、制度发布、高风险驳回和敏感主数据变更执行双人或更高等级复核。 - 收款账户变更、资金支付、制度发布、高风险驳回和敏感主数据变更执行双人或更高等级复核。
@@ -439,6 +442,7 @@ docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv \
- 范围过大费用闭环、AI 学习、价值分析和商业化不能同时全量实现,需要按 P0/P1/P2 阶段交付。 - 范围过大费用闭环、AI 学习、价值分析和商业化不能同时全量实现,需要按 P0/P1/P2 阶段交付。
- 领域模型迁移:旧 `ReimbursementRequest``ExpenseClaim` 和 JSON 状态并存,必须旁路记录、小步迁移和双读校验。 - 领域模型迁移:旧 `ReimbursementRequest``ExpenseClaim` 和 JSON 状态并存,必须旁路记录、小步迁移和双读校验。
- 认证和租户:当前客户端身份头不适合自动化和 SaaS多租户、记忆和高风险动作开发前必须修复。 - 认证和租户:当前客户端身份头不适合自动化和 SaaS多租户、记忆和高风险动作开发前必须修复。
- 会话运维:不透明会话已经替代客户端身份头,但仍需补充定时清理、活跃会话查看/全部退出、密钥轮换策略、登录限流和企业 SSO当前 `tenant_id` 仍是最小契约,不代表跨租户查询守卫已经完成。
- 反馈投毒:一次点击或违规习惯不能直接成为记忆,需要候选态、最小样本、制度约束和结果权重。 - 反馈投毒:一次点击或违规习惯不能直接成为记忆,需要候选态、最小样本、制度约束和结果权重。
- 自动化失控:高准确率不代表高风险动作可以无人值守,必须按动作授权并支持 shadow、Canary、抽检和回滚。 - 自动化失控:高准确率不代表高风险动作可以无人值守,必须按动作授权并支持 shadow、Canary、抽检和回滚。
- 虚假节省:风险暴露金额、暂缓付款和工时估算容易被夸大,必须由客户财务确认并执行去重。 - 虚假节省:风险暴露金额、暂缓付款和工时估算容易被夸大,必须由客户财务确认并执行去重。
@@ -480,3 +484,7 @@ docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv \
- 2026-07-13事务修复关联申请归档/解绑的内部审计改为 `flush`,由外层业务事务统一提交,避免审计日志提前提交付款或归档状态。 - 2026-07-13事务修复关联申请归档/解绑的内部审计改为 `flush`,由外层业务事务统一提交,避免审计日志提前提交付款或归档状态。
- 2026-07-13迁移桥接新增第一条 migration-owned schema revision服务启动先执行 Alembic`create_all` 明确排除三张迁移表。完整历史 schema baseline、正式数据库 upgrade/rollback 和停止旧 DDL 仍未完成。 - 2026-07-13迁移桥接新增第一条 migration-owned schema revision服务启动先执行 Alembic`create_all` 明确排除三张迁移表。完整历史 schema baseline、正式数据库 upgrade/rollback 和停止旧 DDL 仍未完成。
- 2026-07-13验证容器内新增测试与既有差旅主链路回归共 24 项通过Alembic PostgreSQL upgrade/downgrade 离线 SQL、启动脚本语法、OpenAPI 路由和新增文件静态检查通过。未对持久化开发数据库执行迁移。 - 2026-07-13验证容器内新增测试与既有差旅主链路回归共 24 项通过Alembic PostgreSQL upgrade/downgrade 离线 SQL、启动脚本语法、OpenAPI 路由和新增文件静态检查通过。未对持久化开发数据库执行迁移。
- 2026-07-13P0 认证安全切片):新增 `AuthSession` 不透明 Bearer 会话及 `20260713_0002_auth_sessions.py`,登录 token 只返回一次、数据库只保存摘要;`/auth/me`、会话结束和登出均从服务端会话解析身份,登出与使用指标收尾原子提交。
- 2026-07-13管理面收口移除生产代码中的 `X-Auth-*` 授权来源,保护 Settings、模型连通性、缓存、员工、分析、Agent 运行/轨迹、风险观测、审计及系统日志;已初始化 Bootstrap 返回脱敏状态并拒绝匿名重配置Vite Setup 桥同步锁定。
- 2026-07-13前端会话Web 请求、流式响应和页面关闭收尾统一使用 Bearertoken 与过期时间只保存在 `sessionStorage`,集中处理 `401`、空闲过期和服务端过期;业务经理不再被前端视为平台管理员。
- 2026-07-13认证验证容器内认证/Bootstrap/费用事件/OpenAPI 定向测试 21 项通过,受保护业务端点回归 13 项通过,全量测试收集 805 项成功前端会话、请求、Setup 锁和权限测试 17 项通过,生产构建通过。迁移仅生成并检查 upgrade/downgrade 离线 SQL未写入持久化开发数据库。

View File

@@ -48,8 +48,11 @@
## 4. P0 后端实现:费用闭环与数据基础 ## 4. P0 后端实现:费用闭环与数据基础
- [ ] [CONCEPT: 权限与安全] 实现服务端可验证会话/JWT移除客户端身份头作为授权事实来源。 - [x] [CONCEPT: 权限与安全] 实现服务端可验证会话/JWT移除客户端身份头作为授权事实来源。
- [ ] [CONCEPT: 权限与安全] 为管理面、Bootstrap、Settings、模型连通性、日志和规则接口补齐平台管理员保护 证据:`auth_sessions.py``auth_session.py``deps.py``auth.py``authSessionStorage.js`;登录签发不透明 Bearer token数据库仅保存 SHA-256 摘要,生产代码不再信任 `X-Auth-*`,过期/撤销/伪造会话回归测试通过
- [x] [CONCEPT: 权限与安全] 为管理面、Bootstrap、Settings、模型连通性、缓存、审计和系统日志补齐平台管理员保护。
证据:`bootstrap.py``settings.py``audit_logs.py``agent_traces.py``system_logs.py``vite.config.js`;已初始化 Bootstrap 脱敏且拒绝匿名重配,平台管理员/业务经理权限边界和 Vite Setup 锁测试通过。
- [ ] [CONCEPT: 权限与安全] 继续盘点并收口风险规则发布、制度发布及其他尚未纳入本轮的敏感动作,按动作定义平台管理员或双人复核权限。
- [ ] [CONCEPT: 权限与安全] 为所有新增表和共享核心数据补齐最小 `tenant_id`、数据库约束、查询守卫及默认租户迁移。 - [ ] [CONCEPT: 权限与安全] 为所有新增表和共享核心数据补齐最小 `tenant_id`、数据库约束、查询守卫及默认租户迁移。
- [ ] [CONCEPT: 权限与安全] 为 Qdrant collection/namespace、对象存储前缀和缓存键补齐租户隔离回归测试。 - [ ] [CONCEPT: 权限与安全] 为 Qdrant collection/namespace、对象存储前缀和缓存键补齐租户隔离回归测试。
- [ ] [CONCEPT: 数据与契约] 建立 Alembic baseline 和正式迁移链,停止请求路径运行 DDL。 - [ ] [CONCEPT: 数据与契约] 建立 Alembic baseline 和正式迁移链,停止请求路径运行 DDL。
@@ -134,7 +137,9 @@
- [x] [CONCEPT: 测试方案] 完成 Expense Case/Link、业务事件幂等、租户边界、同事务回滚、申请转报销同 Case 和付款归档事件首批测试。 - [x] [CONCEPT: 测试方案] 完成 Expense Case/Link、业务事件幂等、租户边界、同事务回滚、申请转报销同 Case 和付款归档事件首批测试。
证据:容器内 `pytest -q server/tests/test_expense_case_service.py` 7 项通过;联合差旅主链路定向回归共 24 项通过。 证据:容器内 `pytest -q server/tests/test_expense_case_service.py` 7 项通过;联合差旅主链路定向回归共 24 项通过。
- [ ] [CONCEPT: 测试方案] 为 Expense Case 状态机、事件账本、AI 决策、结果、记忆、自动化和节省服务补充单元测试。 - [ ] [CONCEPT: 测试方案] 为 Expense Case 状态机、事件账本、AI 决策、结果、记忆、自动化和节省服务补充单元测试。
- [ ] [CONCEPT: 测试方案] 为服务端会话、管理员保护、租户隔离、跨租户访问和敏感动作补充安全回归测试。 - [x] [CONCEPT: 测试方案] 为服务端会话、管理员保护和 Bootstrap 重配置补充首批安全回归测试。
证据:`test_auth_session_endpoints.py``test_auth_service.py``test_bootstrap_security.py`;容器定向测试覆盖 token 摘要、伪造身份头、过期/撤销、登出原子收尾、业务经理越权和初始化后匿名重配置拒绝。
- [ ] [CONCEPT: 测试方案] 为租户隔离、跨租户访问、规则/制度发布和双人复核等剩余敏感动作补充安全回归测试。
- [ ] [CONCEPT: 测试方案] 为 Alembic baseline、升级、旧数据迁移和回滚边界补充 Postgres 集成测试。 - [ ] [CONCEPT: 测试方案] 为 Alembic baseline、升级、旧数据迁移和回滚边界补充 Postgres 集成测试。
- [ ] [CONCEPT: 测试方案] 为连接器幂等、重试、回执、失败恢复、重复付款和对账补充测试。 - [ ] [CONCEPT: 测试方案] 为连接器幂等、重试、回执、失败恢复、重复付款和对账补充测试。
- [ ] [CONCEPT: 测试方案] 跑通申请 → 票据 → 报销 → 预审 → 审批 → 付款 → 入账 → 归档端到端。 - [ ] [CONCEPT: 测试方案] 跑通申请 → 票据 → 报销 → 预审 → 审批 → 付款 → 入账 → 归档端到端。

View File

@@ -0,0 +1,82 @@
"""add opaque bearer authentication sessions
Revision ID: 20260713_0002
Revises: 20260713_0001
Create Date: 2026-07-13 14:20:00
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "20260713_0002"
down_revision: str | None = "20260713_0001"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"auth_sessions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("token_hash", sa.String(length=64), nullable=False),
sa.Column("tenant_id", sa.String(length=64), nullable=False),
sa.Column("principal_type", sa.String(length=20), nullable=False),
sa.Column("employee_id", sa.String(length=36), nullable=True),
sa.Column("username", sa.String(length=255), nullable=False),
sa.Column("metric_session_id", sa.String(length=64), nullable=False),
sa.Column(
"issued_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"last_seen_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("token_hash", name="uq_auth_sessions_token_hash"),
)
op.create_index("ix_auth_sessions_tenant_id", "auth_sessions", ["tenant_id"])
op.create_index("ix_auth_sessions_principal_type", "auth_sessions", ["principal_type"])
op.create_index("ix_auth_sessions_employee_id", "auth_sessions", ["employee_id"])
op.create_index("ix_auth_sessions_username", "auth_sessions", ["username"])
op.create_index("ix_auth_sessions_metric_session_id", "auth_sessions", ["metric_session_id"])
op.create_index("ix_auth_sessions_expires_at", "auth_sessions", ["expires_at"])
op.create_index("ix_auth_sessions_revoked_at", "auth_sessions", ["revoked_at"])
op.create_index(
"ix_auth_sessions_principal_active",
"auth_sessions",
["principal_type", "revoked_at", "expires_at"],
)
op.create_index(
"ix_auth_sessions_tenant_username",
"auth_sessions",
["tenant_id", "username"],
)
def downgrade() -> None:
op.drop_index("ix_auth_sessions_tenant_username", table_name="auth_sessions")
op.drop_index("ix_auth_sessions_principal_active", table_name="auth_sessions")
op.drop_index("ix_auth_sessions_revoked_at", table_name="auth_sessions")
op.drop_index("ix_auth_sessions_expires_at", table_name="auth_sessions")
op.drop_index("ix_auth_sessions_metric_session_id", table_name="auth_sessions")
op.drop_index("ix_auth_sessions_username", table_name="auth_sessions")
op.drop_index("ix_auth_sessions_employee_id", table_name="auth_sessions")
op.drop_index("ix_auth_sessions_principal_type", table_name="auth_sessions")
op.drop_index("ix_auth_sessions_tenant_id", table_name="auth_sessions")
op.drop_table("auth_sessions")

View File

@@ -6,10 +6,8 @@ from fastapi import Depends, Header, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.db.session import get_session_factory from app.db.session import get_session_factory
from app.services.auth import AuthService
from app.services.auth_sessions import AuthSessionService
PLATFORM_ADMIN_IDENTITIES = {"admin", "superadmin"}
ADMIN_HEADER_TRUE_VALUES = {"1", "true", "yes", "on"}
def get_db() -> Generator[Session, None, None]: def get_db() -> Generator[Session, None, None]:
@@ -33,82 +31,72 @@ class CurrentUserContext:
grade: str = "" grade: str = ""
employee_no: str = "" employee_no: str = ""
manager_name: str = "" manager_name: str = ""
employee_id: str = ""
auth_session_id: str = ""
def get_current_user( def get_current_user(
x_auth_username: Annotated[ db: Annotated[Session, Depends(get_db)],
authorization: Annotated[
str | None, str | None,
Header(description="当前登录用户名。知识库接口至少需要提供用户名或姓名"), Header(description="登录接口签发的 `Authorization: Bearer <token>`"),
] = None,
x_auth_name: Annotated[
str | None,
Header(description="当前登录人展示姓名。未传时默认回退到用户名。"),
] = None,
x_auth_role_codes: Annotated[
str | None,
Header(description="角色编码列表,多个角色使用英文逗号分隔,例如 `manager,finance`。"),
] = None,
x_auth_is_admin: Annotated[
str | None,
Header(description="是否管理员,支持 `true/false/1/0`。"),
] = None,
x_auth_department: Annotated[
str | None,
Header(description="当前登录人的所属部门。"),
] = None,
x_auth_cost_center: Annotated[
str | None,
Header(description="当前登录人的成本中心。"),
] = None,
x_auth_position: Annotated[
str | None,
Header(description="当前登录人的岗位。"),
] = None,
x_auth_grade: Annotated[
str | None,
Header(description="当前登录人的职级。"),
] = None,
x_auth_employee_no: Annotated[
str | None,
Header(description="当前登录人的员工编号。"),
] = None,
x_auth_manager_name: Annotated[
str | None,
Header(description="当前登录人的直属领导。"),
] = None, ] = None,
) -> CurrentUserContext: ) -> CurrentUserContext:
role_codes = [ return _authenticate_bearer_user(db, authorization)
_normalize_role_code(item)
for item in (x_auth_role_codes or "").split(",")
if _normalize_role_code(item)
]
username = (x_auth_username or "").strip()
name = (x_auth_name or username).strip()
is_admin = _resolve_platform_admin_flag(
username=username,
name=name,
role_codes=role_codes,
header_value=x_auth_is_admin,
)
if not username and not name:
raise HTTPException( def get_optional_current_user(
status_code=status.HTTP_401_UNAUTHORIZED, db: Annotated[Session, Depends(get_db)],
detail="请先登录后再访问知识库。", authorization: Annotated[
) str | None,
Header(description="系统已初始化时用于管理员校验的 Bearer Token。"),
] = None,
) -> CurrentUserContext | None:
if not str(authorization or "").strip():
return None
return _authenticate_bearer_user(db, authorization)
def _authenticate_bearer_user(db: Session, authorization: str | None) -> CurrentUserContext:
access_token = _extract_bearer_token(authorization)
auth_session = AuthSessionService(db).authenticate(access_token)
if auth_session is None:
raise _unauthorized("登录会话不存在、已过期或已退出,请重新登录。")
user = AuthService(db).get_session_user(auth_session)
if user is None:
raise _unauthorized("当前登录用户不存在、已停用或权限已失效。")
return CurrentUserContext( return CurrentUserContext(
username=username or name, username=user.username,
name=name or username, name=user.name,
role_codes=role_codes, role_codes=[_normalize_role_code(item) for item in user.role_codes],
is_admin=is_admin, is_admin=user.is_admin,
tenant_id="default", tenant_id=user.tenant_id,
department_name=(x_auth_department or "").strip(), department_name=user.department,
cost_center=(x_auth_cost_center or "").strip(), cost_center=user.cost_center,
position=(x_auth_position or "").strip(), position=user.position,
grade=(x_auth_grade or "").strip(), grade=user.grade,
employee_no=(x_auth_employee_no or "").strip(), employee_no=user.employee_no,
manager_name=(x_auth_manager_name or "").strip(), manager_name=user.manager_name,
employee_id=user.employee_id or "",
auth_session_id=auth_session.id,
)
def _extract_bearer_token(authorization: str | None) -> str:
normalized = str(authorization or "").strip()
scheme, separator, token = normalized.partition(" ")
if not separator or scheme.casefold() != "bearer" or not token.strip():
raise _unauthorized("请先登录后再访问。")
return token.strip()
def _unauthorized(detail: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=detail,
headers={"WWW-Authenticate": "Bearer"},
) )
@@ -120,25 +108,11 @@ def _normalize_role_code(value: str | None) -> str:
def _current_user_role_codes(current_user: CurrentUserContext) -> set[str]: def _current_user_role_codes(current_user: CurrentUserContext) -> set[str]:
return {_normalize_role_code(item) for item in current_user.role_codes if _normalize_role_code(item)} return {
_normalize_role_code(item)
for item in current_user.role_codes
def _resolve_platform_admin_flag( if _normalize_role_code(item)
*,
username: str,
name: str,
role_codes: list[str],
header_value: str | None,
) -> bool:
if str(header_value or "").strip().lower() in ADMIN_HEADER_TRUE_VALUES:
return True
identities = {
str(username or "").strip().lower(),
str(name or "").strip().lower(),
} }
normalized_role_codes = {_normalize_role_code(item) for item in role_codes}
return bool(identities & PLATFORM_ADMIN_IDENTITIES) or bool(normalized_role_codes & PLATFORM_ADMIN_IDENTITIES)
def require_admin_user( def require_admin_user(

View File

@@ -5,12 +5,12 @@ from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_db from app.api.deps import get_current_user, get_db
from app.schemas.agent_run import AgentRunRead, AgentRunStatsRead from app.schemas.agent_run import AgentRunRead, AgentRunStatsRead
from app.schemas.common import ErrorResponse from app.schemas.common import ErrorResponse
from app.services.agent_runs import AgentRunService from app.services.agent_runs import AgentRunService
router = APIRouter(prefix="/agent-runs") router = APIRouter(prefix="/agent-runs", dependencies=[Depends(get_current_user)])
DbSession = Annotated[Session, Depends(get_db)] DbSession = Annotated[Session, Depends(get_db)]

View File

@@ -5,7 +5,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_db, require_admin_user from app.api.deps import get_db, require_platform_admin_user
from app.schemas.agent_trace import ( from app.schemas.agent_trace import (
AgentConversationTraceRead, AgentConversationTraceRead,
AgentTraceDetailRead, AgentTraceDetailRead,
@@ -26,7 +26,7 @@ DbSession = Annotated[Session, Depends(get_db)]
) )
def list_agent_traces( def list_agent_traces(
db: DbSession, db: DbSession,
_: Annotated[object, Depends(require_admin_user)], _: Annotated[object, Depends(require_platform_admin_user)],
agent: Annotated[str | None, Query(description="Agent 名称过滤。")] = None, agent: Annotated[str | None, Query(description="Agent 名称过滤。")] = None,
status_value: Annotated[ status_value: Annotated[
str | None, str | None,
@@ -56,7 +56,7 @@ def list_agent_traces(
def get_conversation_trace( def get_conversation_trace(
conversation_id: str, conversation_id: str,
db: DbSession, db: DbSession,
_: Annotated[object, Depends(require_admin_user)], _: Annotated[object, Depends(require_platform_admin_user)],
) -> AgentConversationTraceRead: ) -> AgentConversationTraceRead:
return AgentTraceService(db).get_conversation_trace(conversation_id) return AgentTraceService(db).get_conversation_trace(conversation_id)
@@ -76,7 +76,7 @@ def get_conversation_trace(
def get_agent_trace( def get_agent_trace(
run_id: str, run_id: str,
db: DbSession, db: DbSession,
_: Annotated[object, Depends(require_admin_user)], _: Annotated[object, Depends(require_platform_admin_user)],
) -> AgentTraceDetailRead: ) -> AgentTraceDetailRead:
trace = AgentTraceService(db).get_trace(run_id) trace = AgentTraceService(db).get_trace(run_id)
if trace is None: if trace is None:

View File

@@ -6,7 +6,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_db from app.api.deps import get_current_user, get_db
from app.schemas.digital_employee_dashboard import DigitalEmployeeDashboardRead from app.schemas.digital_employee_dashboard import DigitalEmployeeDashboardRead
from app.schemas.finance_dashboard import FinanceDashboardRead from app.schemas.finance_dashboard import FinanceDashboardRead
from app.schemas.system_dashboard import SystemDashboardRead from app.schemas.system_dashboard import SystemDashboardRead
@@ -14,7 +14,7 @@ from app.services.digital_employee_dashboard import DigitalEmployeeDashboardServ
from app.services.finance_dashboard_snapshot import FinanceDashboardSnapshotService from app.services.finance_dashboard_snapshot import FinanceDashboardSnapshotService
from app.services.system_dashboard import SystemDashboardService from app.services.system_dashboard import SystemDashboardService
router = APIRouter(prefix="/analytics") router = APIRouter(prefix="/analytics", dependencies=[Depends(get_current_user)])
DbSession = Annotated[Session, Depends(get_db)] DbSession = Annotated[Session, Depends(get_db)]

View File

@@ -5,11 +5,11 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_db from app.api.deps import get_db, require_platform_admin_user
from app.schemas.audit_log import AuditLogRead from app.schemas.audit_log import AuditLogRead
from app.services.audit import AuditLogService from app.services.audit import AuditLogService
router = APIRouter(prefix="/audit-logs") router = APIRouter(prefix="/audit-logs", dependencies=[Depends(require_platform_admin_user)])
DbSession = Annotated[Session, Depends(get_db)] DbSession = Annotated[Session, Depends(get_db)]

View File

@@ -10,11 +10,14 @@ from app.schemas.auth import (
AuthUserRead, AuthUserRead,
LoginRequest, LoginRequest,
LoginResponse, LoginResponse,
LogoutRequest,
LogoutResponse,
SessionFinishRequest, SessionFinishRequest,
SessionFinishResponse, SessionFinishResponse,
) )
from app.schemas.common import ErrorResponse from app.schemas.common import ErrorResponse
from app.services.auth import AuthService from app.services.auth import AuthService
from app.services.auth_sessions import AuthSessionService
from app.services.user_session_metrics import UserSessionMetricService from app.services.user_session_metrics import UserSessionMetricService
router = APIRouter(prefix="/auth") router = APIRouter(prefix="/auth")
@@ -44,7 +47,7 @@ def login(payload: LoginRequest, db: DbSession) -> LoginResponse:
"/me", "/me",
response_model=AuthUserRead, response_model=AuthUserRead,
summary="读取当前登录用户", summary="读取当前登录用户",
description="根据当前会话请求头刷新前端登录态中的员工姓名、部门、岗位和职级。", description="根据 Bearer 会话刷新前端登录态中的员工姓名、部门、岗位和职级。",
) )
def get_current_auth_user( def get_current_auth_user(
current_user: Annotated[CurrentUserContext, Depends(get_current_user)], current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
@@ -68,12 +71,49 @@ def get_current_auth_user(
managerName=current_user.manager_name, managerName=current_user.manager_name,
costCenter=current_user.cost_center, costCenter=current_user.cost_center,
roleCodes=current_user.role_codes or ["manager"], roleCodes=current_user.role_codes or ["manager"],
email=current_user.username if "@" in current_user.username else f"{current_user.username}@local", email=(
current_user.username
if "@" in current_user.username
else f"{current_user.username}@local"
),
avatar=name[:1].upper(), avatar=name[:1].upper(),
isAdmin=True, isAdmin=True,
) )
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="当前登录用户不存在或已停用") raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="当前登录用户不存在或已停用",
)
@router.post(
"/logout",
response_model=LogoutResponse,
summary="退出当前登录会话",
description="撤销当前 Bearer Token撤销后该凭证不可再次使用。",
)
def logout(
payload: LogoutRequest,
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
db: DbSession,
) -> LogoutResponse:
try:
if payload.sessionId:
UserSessionMetricService(db).finish_session(
session_id=payload.sessionId,
expected_username=current_user.username,
reason=payload.reason,
last_activity_at=payload.lastActivityAt,
activity_event_count=payload.activityEventCount,
event={"page_path": payload.pagePath},
commit=False,
)
AuthSessionService(db).revoke(current_user.auth_session_id, commit=False)
db.commit()
except Exception:
db.rollback()
raise
return LogoutResponse()
@router.post( @router.post(
@@ -84,10 +124,12 @@ def get_current_auth_user(
def finish_session( def finish_session(
session_id: str, session_id: str,
payload: SessionFinishRequest, payload: SessionFinishRequest,
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
db: DbSession, db: DbSession,
) -> SessionFinishResponse: ) -> SessionFinishResponse:
session = UserSessionMetricService(db).finish_session( session = UserSessionMetricService(db).finish_session(
session_id=session_id, session_id=session_id,
expected_username=current_user.username,
reason=payload.reason, reason=payload.reason,
last_activity_at=payload.lastActivityAt, last_activity_at=payload.lastActivityAt,
activity_event_count=payload.activityEventCount, activity_event_count=payload.activityEventCount,

View File

@@ -1,7 +1,10 @@
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, status from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from app.api.deps import CurrentUserContext, get_optional_current_user
from app.core.bootstrap import build_bootstrap_state, persist_bootstrap_config from app.core.bootstrap import build_bootstrap_state, persist_bootstrap_config
from app.core.config import get_settings from app.core.config import get_settings
from app.schemas.bootstrap import BootstrapSetupPayload, BootstrapStateRead from app.schemas.bootstrap import BootstrapSetupPayload, BootstrapStateRead
@@ -16,7 +19,11 @@ router = APIRouter(prefix="/bootstrap")
description="返回当前系统是否已完成初始化,以及公司、数据库和缓存配置快照。", description="返回当前系统是否已完成初始化,以及公司、数据库和缓存配置快照。",
) )
def get_bootstrap_state() -> BootstrapStateRead: def get_bootstrap_state() -> BootstrapStateRead:
return build_bootstrap_state(get_settings()) settings = get_settings()
return build_bootstrap_state(
settings,
redact_infrastructure=settings.setup_completed,
)
@router.post( @router.post(
@@ -26,5 +33,17 @@ def get_bootstrap_state() -> BootstrapStateRead:
summary="写入初始化配置", summary="写入初始化配置",
description="保存系统初始化配置,并刷新运行时数据库连接。", description="保存系统初始化配置,并刷新运行时数据库连接。",
) )
def initialize_bootstrap(payload: BootstrapSetupPayload) -> BootstrapStateRead: def initialize_bootstrap(
return persist_bootstrap_config(payload, get_settings()) payload: BootstrapSetupPayload,
current_user: Annotated[
CurrentUserContext | None,
Depends(get_optional_current_user),
],
) -> BootstrapStateRead:
settings = get_settings()
if settings.setup_completed and (current_user is None or not current_user.is_admin):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="系统已完成初始化,只有平台管理员可以重新写入初始化配置。",
)
return persist_bootstrap_config(payload, settings)

View File

@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile,
from fastapi.responses import Response from fastapi.responses import Response
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_db from app.api.deps import get_db, require_admin_user
from app.api.pagination import PageNumber, PageSize, page_payload, wants_page from app.api.pagination import PageNumber, PageSize, page_payload, wants_page
from app.schemas.common import ErrorResponse, PaginatedResponse from app.schemas.common import ErrorResponse, PaginatedResponse
from app.schemas.employee import ( from app.schemas.employee import (
@@ -19,7 +19,7 @@ from app.schemas.employee import (
from app.services.employee import EmployeeService from app.services.employee import EmployeeService
from app.services.employee_pagination import EmployeePaginationService from app.services.employee_pagination import EmployeePaginationService
router = APIRouter() router = APIRouter(dependencies=[Depends(require_admin_user)])
DbSession = Annotated[Session, Depends(get_db)] DbSession = Annotated[Session, Depends(get_db)]

View File

@@ -5,7 +5,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_db from app.api.deps import get_current_user, get_db
from app.schemas.common import ErrorResponse from app.schemas.common import ErrorResponse
from app.schemas.risk_observation import ( from app.schemas.risk_observation import (
RiskObservationDashboardRead, RiskObservationDashboardRead,
@@ -16,7 +16,7 @@ from app.schemas.risk_observation import (
) )
from app.services.risk_observations import RiskObservationService from app.services.risk_observations import RiskObservationService
router = APIRouter(prefix="/risk-observations") router = APIRouter(prefix="/risk-observations", dependencies=[Depends(get_current_user)])
DbSession = Annotated[Session, Depends(get_db)] DbSession = Annotated[Session, Depends(get_db)]

View File

@@ -5,7 +5,12 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Header, HTTPException, status from fastapi import APIRouter, Depends, Header, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext, get_db, require_admin_user from app.api.deps import (
CurrentUserContext,
get_current_user,
get_db,
require_platform_admin_user,
)
from app.core.config import get_settings as get_runtime_settings from app.core.config import get_settings as get_runtime_settings
from app.schemas.common import ErrorResponse from app.schemas.common import ErrorResponse
from app.schemas.settings import ( from app.schemas.settings import (
@@ -52,7 +57,10 @@ def require_hermes_agent_token(
summary="读取系统设置", summary="读取系统设置",
description="返回公司、管理员、模型、日志、邮件和 ONLYOFFICE 的设置快照。", description="返回公司、管理员、模型、日志、邮件和 ONLYOFFICE 的设置快照。",
) )
def get_settings(db: DbSession) -> SettingsRead: def get_settings(
db: DbSession,
_: Annotated[CurrentUserContext, Depends(get_current_user)],
) -> SettingsRead:
return SettingsService(db).get_settings_snapshot() return SettingsService(db).get_settings_snapshot()
@@ -68,7 +76,11 @@ def get_settings(db: DbSession) -> SettingsRead:
} }
}, },
) )
def update_settings(payload: SettingsWrite, db: DbSession) -> SettingsRead: def update_settings(
payload: SettingsWrite,
db: DbSession,
_: Annotated[CurrentUserContext, Depends(require_platform_admin_user)],
) -> SettingsRead:
try: try:
return SettingsService(db).save_settings_snapshot(payload) return SettingsService(db).save_settings_snapshot(payload)
except ValueError as exc: except ValueError as exc:
@@ -84,6 +96,7 @@ def update_settings(payload: SettingsWrite, db: DbSession) -> SettingsRead:
def test_model_connectivity( def test_model_connectivity(
payload: ModelConnectivityTestRequest, payload: ModelConnectivityTestRequest,
db: DbSession, db: DbSession,
_: Annotated[CurrentUserContext, Depends(require_platform_admin_user)],
) -> ModelConnectivityTestRead: ) -> ModelConnectivityTestRead:
resolved_payload = payload resolved_payload = payload
@@ -99,7 +112,10 @@ def test_model_connectivity(
"/cache/clear", "/cache/clear",
response_model=SettingsCacheClearRead, response_model=SettingsCacheClearRead,
summary="清理系统缓存", summary="清理系统缓存",
description="清理 OCR、模型失败冷却、知识库索引和运行时配置等进程内缓存不删除业务文件或数据库记录。", description=(
"清理 OCR、模型失败冷却、知识库索引和运行时配置等进程内缓存"
"不删除业务文件或数据库记录。"
),
responses={ responses={
status.HTTP_403_FORBIDDEN: { status.HTTP_403_FORBIDDEN: {
"model": ErrorResponse, "model": ErrorResponse,
@@ -108,7 +124,7 @@ def test_model_connectivity(
}, },
) )
def clear_system_cache( def clear_system_cache(
_: Annotated[CurrentUserContext, Depends(require_admin_user)], _: Annotated[CurrentUserContext, Depends(require_platform_admin_user)],
) -> SettingsCacheClearRead: ) -> SettingsCacheClearRead:
return SystemCacheService().clear_all() return SystemCacheService().clear_all()

View File

@@ -4,7 +4,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from app.api.deps import CurrentUserContext, require_admin_user from app.api.deps import CurrentUserContext, require_platform_admin_user
from app.schemas.common import ErrorResponse from app.schemas.common import ErrorResponse
from app.schemas.system_log import SystemLogEntryRead, SystemLogFileRead, SystemLogTailRead from app.schemas.system_log import SystemLogEntryRead, SystemLogFileRead, SystemLogTailRead
from app.services.system_logs import SystemLogService from app.services.system_logs import SystemLogService
@@ -25,7 +25,7 @@ router = APIRouter(prefix="/system-logs")
}, },
) )
def list_system_log_entries( def list_system_log_entries(
_: Annotated[CurrentUserContext, Depends(require_admin_user)], _: Annotated[CurrentUserContext, Depends(require_platform_admin_user)],
limit: Annotated[int, Query(ge=20, le=1000, description="返回的日志记录数。")] = 300, limit: Annotated[int, Query(ge=20, le=1000, description="返回的日志记录数。")] = 300,
) -> list[SystemLogEntryRead]: ) -> list[SystemLogEntryRead]:
return SystemLogService().list_entries(entry_limit=limit) return SystemLogService().list_entries(entry_limit=limit)
@@ -49,12 +49,15 @@ def list_system_log_entries(
) )
def get_system_log_entry( def get_system_log_entry(
entry_id: str, entry_id: str,
_: Annotated[CurrentUserContext, Depends(require_admin_user)], _: Annotated[CurrentUserContext, Depends(require_platform_admin_user)],
) -> SystemLogEntryRead: ) -> SystemLogEntryRead:
try: try:
return SystemLogService().get_entry(entry_id) return SystemLogService().get_entry(entry_id)
except FileNotFoundError as exc: except FileNotFoundError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="日志记录不存在。") from exc raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="日志记录不存在。",
) from exc
@router.get( @router.get(
@@ -70,7 +73,7 @@ def get_system_log_entry(
}, },
) )
def list_system_log_files( def list_system_log_files(
_: Annotated[CurrentUserContext, Depends(require_admin_user)], _: Annotated[CurrentUserContext, Depends(require_platform_admin_user)],
) -> list[SystemLogFileRead]: ) -> list[SystemLogFileRead]:
return SystemLogService().list_files() return SystemLogService().list_files()
@@ -93,10 +96,13 @@ def list_system_log_files(
) )
def get_system_log_tail( def get_system_log_tail(
file_name: str, file_name: str,
_: Annotated[CurrentUserContext, Depends(require_admin_user)], _: Annotated[CurrentUserContext, Depends(require_platform_admin_user)],
lines: Annotated[int, Query(ge=20, le=1000, description="返回的日志行数。")] = 300, lines: Annotated[int, Query(ge=20, le=1000, description="返回的日志行数。")] = 300,
) -> SystemLogTailRead: ) -> SystemLogTailRead:
try: try:
return SystemLogService().read_tail(file_name, line_limit=lines) return SystemLogService().read_tail(file_name, line_limit=lines)
except FileNotFoundError as exc: except FileNotFoundError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="日志文件不存在。") from exc raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="日志文件不存在。",
) from exc

View File

@@ -34,7 +34,11 @@ def build_database_url(payload: BootstrapSetupPayload) -> str:
) )
def build_bootstrap_state(settings: Settings) -> BootstrapStateRead: def build_bootstrap_state(
settings: Settings,
*,
redact_infrastructure: bool = False,
) -> BootstrapStateRead:
return BootstrapStateRead( return BootstrapStateRead(
initialized=settings.setup_completed, initialized=settings.setup_completed,
company={ company={
@@ -46,17 +50,23 @@ def build_bootstrap_state(settings: Settings) -> BootstrapStateRead:
server={"host": settings.app_host, "port": settings.app_port}, server={"host": settings.app_host, "port": settings.app_port},
database={ database={
"driver": "postgresql", "driver": "postgresql",
"host": settings.postgres_host, "host": "" if redact_infrastructure else settings.postgres_host,
"port": settings.postgres_port, "port": settings.postgres_port,
"name": settings.postgres_db, "name": settings.postgres_db,
"username": settings.postgres_user, "username": "" if redact_infrastructure else settings.postgres_user,
"password_configured": bool(settings.postgres_password), "password_configured": bool(settings.postgres_password),
}, },
redis={"enabled": bool(settings.redis_url), "url": settings.redis_url or ""}, redis={
"enabled": bool(settings.redis_url),
"url": "" if redact_infrastructure else settings.redis_url or "",
},
) )
def persist_bootstrap_config(payload: BootstrapSetupPayload, settings: Settings) -> BootstrapStateRead: def persist_bootstrap_config(
payload: BootstrapSetupPayload,
settings: Settings,
) -> BootstrapStateRead:
env_file = ensure_env_file() env_file = ensure_env_file()
database_url = build_database_url(payload) database_url = build_database_url(payload)
vite_api_base_url = f"http://{settings.app_host}:{settings.app_port}{settings.api_v1_prefix}" vite_api_base_url = f"http://{settings.app_host}:{settings.app_port}{settings.api_v1_prefix}"

View File

@@ -9,11 +9,9 @@ X-Financial 后端 OpenAPI 文档。
## 鉴权约定 ## 鉴权约定
- 知识库接口依赖以下请求头模拟当前用户 - 用户登录成功后,业务接口统一使用
- `X-Auth-Username` - `Authorization: Bearer <accessToken>`
- `X-Auth-Name` - 用户名、角色和管理员身份均由服务端会话与数据库目录解析,不接受浏览器自报身份。
- `X-Auth-Role-Codes`
- `X-Auth-Is-Admin`
- Agent 资产写接口支持以下审计头: - Agent 资产写接口支持以下审计头:
- `X-Actor` - `X-Actor`
- `X-Request-Id` - `X-Request-Id`

View File

@@ -1,5 +1,4 @@
from app.db.base_class import Base from app.db.base_class import Base
from app.models.agent_conversation import AgentConversation, AgentConversationMessage
from app.models.agent_asset import ( from app.models.agent_asset import (
AgentAsset, AgentAsset,
AgentAssetReview, AgentAssetReview,
@@ -7,14 +6,16 @@ from app.models.agent_asset import (
AgentAssetTestRun, AgentAssetTestRun,
AgentAssetVersion, AgentAssetVersion,
) )
from app.models.agent_conversation import AgentConversation, AgentConversationMessage
from app.models.agent_feedback import AgentOperationFeedback from app.models.agent_feedback import AgentOperationFeedback
from app.models.agent_run import AgentRun, AgentToolCall, AgentTraceEvent, SemanticParseLog from app.models.agent_run import AgentRun, AgentToolCall, AgentTraceEvent, SemanticParseLog
from app.models.approval import ApprovalRecord from app.models.approval import ApprovalRecord
from app.models.audit_log import AuditLog from app.models.audit_log import AuditLog
from app.models.auth_session import AuthSession
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
from app.models.employee_change_log import EmployeeChangeLog
from app.models.employee_behavior_profile import EmployeeBehaviorProfileSnapshot
from app.models.employee import Employee from app.models.employee import Employee
from app.models.employee_behavior_profile import EmployeeBehaviorProfileSnapshot
from app.models.employee_change_log import EmployeeChangeLog
from app.models.expense_case import BusinessEvent, ExpenseCase, ExpenseCaseLink from app.models.expense_case import BusinessEvent, ExpenseCase, ExpenseCaseLink
from app.models.few_shot_sample import FewShotSample from app.models.few_shot_sample import FewShotSample
from app.models.financial_record import ( from app.models.financial_record import (
@@ -53,6 +54,7 @@ __all__ = [
"AgentTraceEvent", "AgentTraceEvent",
"ApprovalRecord", "ApprovalRecord",
"AuditLog", "AuditLog",
"AuthSession",
"BudgetAllocation", "BudgetAllocation",
"BudgetReservation", "BudgetReservation",
"BudgetTransaction", "BudgetTransaction",

View File

@@ -1,13 +1,19 @@
from app.models.agent_asset import (
AgentAsset,
AgentAssetReview,
AgentAssetRuleFeedback,
AgentAssetVersion,
)
from app.models.agent_conversation import AgentConversation, AgentConversationMessage from app.models.agent_conversation import AgentConversation, AgentConversationMessage
from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetRuleFeedback, AgentAssetVersion
from app.models.agent_feedback import AgentOperationFeedback from app.models.agent_feedback import AgentOperationFeedback
from app.models.agent_run import AgentRun, AgentToolCall, AgentTraceEvent, SemanticParseLog from app.models.agent_run import AgentRun, AgentToolCall, AgentTraceEvent, SemanticParseLog
from app.models.approval import ApprovalRecord from app.models.approval import ApprovalRecord
from app.models.audit_log import AuditLog from app.models.audit_log import AuditLog
from app.models.auth_session import AuthSession
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
from app.models.employee_change_log import EmployeeChangeLog
from app.models.employee_behavior_profile import EmployeeBehaviorProfileSnapshot
from app.models.employee import Employee from app.models.employee import Employee
from app.models.employee_behavior_profile import EmployeeBehaviorProfileSnapshot
from app.models.employee_change_log import EmployeeChangeLog
from app.models.expense_case import BusinessEvent, ExpenseCase, ExpenseCaseLink from app.models.expense_case import BusinessEvent, ExpenseCase, ExpenseCaseLink
from app.models.few_shot_sample import FewShotSample from app.models.few_shot_sample import FewShotSample
from app.models.financial_record import ( from app.models.financial_record import (
@@ -44,6 +50,7 @@ __all__ = [
"AgentTraceEvent", "AgentTraceEvent",
"ApprovalRecord", "ApprovalRecord",
"AuditLog", "AuditLog",
"AuthSession",
"BudgetAllocation", "BudgetAllocation",
"BudgetReservation", "BudgetReservation",
"BudgetTransaction", "BudgetTransaction",

View File

@@ -0,0 +1,34 @@
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Index, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base
class AuthSession(Base):
__tablename__ = "auth_sessions"
__table_args__ = (
Index("ix_auth_sessions_principal_active", "principal_type", "revoked_at", "expires_at"),
Index("ix_auth_sessions_tenant_username", "tenant_id", "username"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
token_hash: Mapped[str] = mapped_column(String(64), unique=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
principal_type: Mapped[str] = mapped_column(String(20), index=True)
employee_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
username: Mapped[str] = mapped_column(String(255), index=True)
metric_session_id: Mapped[str] = mapped_column(String(64), default="", index=True)
issued_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
revoked_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
last_seen_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

View File

@@ -36,6 +36,14 @@ class LoginResponse(BaseModel):
detail: str = "登录成功。" detail: str = "登录成功。"
user: AuthUserRead user: AuthUserRead
sessionId: str = "" sessionId: str = ""
accessToken: str
tokenType: str = "Bearer"
expiresAt: datetime
class LogoutResponse(BaseModel):
ok: bool = True
detail: str = "已退出登录。"
class SessionFinishRequest(BaseModel): class SessionFinishRequest(BaseModel):
@@ -45,6 +53,10 @@ class SessionFinishRequest(BaseModel):
pagePath: str = Field(default="", max_length=512) pagePath: str = Field(default="", max_length=512)
class LogoutRequest(SessionFinishRequest):
sessionId: str = Field(default="", max_length=64)
class SessionFinishResponse(BaseModel): class SessionFinishResponse(BaseModel):
ok: bool = True ok: bool = True
detail: str = "会话已结算。" detail: str = "会话已结算。"

View File

@@ -39,6 +39,7 @@ logger = get_logger("app.services.agent_foundation")
_foundation_ready_lock = threading.RLock() _foundation_ready_lock = threading.RLock()
_foundation_ready_keys: set[str] = set() _foundation_ready_keys: set[str] = set()
MIGRATION_OWNED_TABLES = { MIGRATION_OWNED_TABLES = {
"auth_sessions",
"expense_cases", "expense_cases",
"expense_case_links", "expense_case_links",
"business_events", "business_events",

View File

@@ -11,9 +11,11 @@ from sqlalchemy.orm import Session, selectinload
from app.core.config import get_settings from app.core.config import get_settings
from app.core.logging import get_logger from app.core.logging import get_logger
from app.core.security import verify_password from app.core.security import verify_password
from app.models.auth_session import AuthSession
from app.models.employee import Employee from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim from app.models.financial_record import ExpenseClaim
from app.schemas.auth import AuthUserRead, LoginRequest, LoginResponse from app.schemas.auth import AuthUserRead, LoginRequest, LoginResponse
from app.services.auth_sessions import AuthSessionService
from app.services.employee import EmployeeService from app.services.employee import EmployeeService
from app.services.employee_seed import ROLE_DISPLAY_ORDER from app.services.employee_seed import ROLE_DISPLAY_ORDER
from app.services.settings import SettingsService from app.services.settings import SettingsService
@@ -50,6 +52,8 @@ class AuthenticatedUser:
email: str email: str
avatar: str avatar: str
is_admin: bool = False is_admin: bool = False
employee_id: str | None = None
tenant_id: str = "default"
class AuthService: class AuthService:
@@ -79,12 +83,61 @@ class AuthService:
raise ValueError("账号或密码错误。") raise ValueError("账号或密码错误。")
def _build_login_response(self, user: AuthenticatedUser) -> LoginResponse: def _build_login_response(self, user: AuthenticatedUser) -> LoginResponse:
session = UserSessionMetricService(self.db).start_session(user) settings_snapshot = SettingsService(self.db).get_settings_snapshot()
return LoginResponse(user=self._serialize_user(user), sessionId=session.session_id) timeout_minutes = settings_snapshot.adminForm.sessionTimeout
try:
metric_session = UserSessionMetricService(self.db).start_session(user, commit=False)
access_token, auth_session = AuthSessionService(self.db).issue(
user,
metric_session_id=metric_session.session_id,
timeout_minutes=timeout_minutes,
)
self.db.commit()
self.db.refresh(auth_session)
except Exception:
self.db.rollback()
raise
return LoginResponse(
user=self._serialize_user(user),
sessionId=metric_session.session_id,
accessToken=access_token,
expiresAt=auth_session.expires_at,
)
def get_session_user(self, auth_session: AuthSession) -> AuthenticatedUser | None:
if auth_session.principal_type == "admin":
record = SettingsService(self.db).get_admin_credentials()
if record is None:
return None
allowed_identifiers = {
str(record.account or "").strip().casefold(),
str(record.email or "").strip().casefold(),
}
if auth_session.username.strip().casefold() not in allowed_identifiers:
return None
return self._build_admin_user(record)
if auth_session.principal_type != "employee":
return None
stmt = select(Employee).options(
selectinload(Employee.organization_unit),
selectinload(Employee.manager),
selectinload(Employee.roles),
)
if auth_session.employee_id:
stmt = stmt.where(Employee.id == auth_session.employee_id)
else:
stmt = stmt.where(func.lower(Employee.email) == auth_session.username.lower())
employee = self.db.execute(stmt).scalars().first()
if employee is None or employee.employment_status == "停用":
return None
return self._build_employee_user(employee)
def get_user_snapshot(self, identifier: str) -> AuthUserRead | None: def get_user_snapshot(self, identifier: str) -> AuthUserRead | None:
normalized = identifier.strip() normalized = identifier.strip()
if not normalized or not self.settings.setup_completed: if not normalized:
return None return None
employee = self._find_employee_by_email(normalized) employee = self._find_employee_by_email(normalized)
@@ -101,6 +154,10 @@ class AuthService:
if record is None: if record is None:
return None return None
return self._build_admin_user(record)
@staticmethod
def _build_admin_user(record: Any) -> AuthenticatedUser:
admin_username = record.account.strip() admin_username = record.account.strip()
admin_email = record.email.strip() admin_email = record.email.strip()
display_name = admin_username or admin_email or "系统管理员" display_name = admin_username or admin_email or "系统管理员"
@@ -169,7 +226,9 @@ class AuthService:
) )
role_codes = [role.role_code for role in sorted_roles] role_codes = [role.role_code for role in sorted_roles]
primary_role_code = role_codes[0] if role_codes else "user" primary_role_code = role_codes[0] if role_codes else "user"
department = employee.organization_unit.name if employee.organization_unit is not None else "" department = (
employee.organization_unit.name if employee.organization_unit is not None else ""
)
manager_name = self._resolve_manager_name(employee) manager_name = self._resolve_manager_name(employee)
return AuthenticatedUser( return AuthenticatedUser(
@@ -189,6 +248,7 @@ class AuthService:
email=employee.email, email=employee.email,
avatar=(employee.name or "?")[:1].upper(), avatar=(employee.name or "?")[:1].upper(),
is_admin=False, is_admin=False,
employee_id=employee.id,
) )
@staticmethod @staticmethod
@@ -235,7 +295,11 @@ class AuthService:
"riskyClaimCount": sum(1 for claim in claims if claim.risk_flags_json), "riskyClaimCount": sum(1 for claim in claims if claim.risk_flags_json),
"draftClaimCount": sum(1 for claim in claims if claim.status == "draft"), "draftClaimCount": sum(1 for claim in claims if claim.status == "draft"),
"recentRiskFlags": recent_risk_flags, "recentRiskFlags": recent_risk_flags,
"lastClaimAt": claims[0].occurred_at.isoformat() if claims and claims[0].occurred_at else "", "lastClaimAt": (
claims[0].occurred_at.isoformat()
if claims and claims[0].occurred_at
else ""
),
} }
@staticmethod @staticmethod

View File

@@ -0,0 +1,96 @@
from __future__ import annotations
import hashlib
import secrets
from datetime import UTC, datetime, timedelta
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.auth_session import AuthSession
DEFAULT_SESSION_TIMEOUT_MINUTES = 30
MIN_SESSION_TIMEOUT_MINUTES = 5
MAX_SESSION_TIMEOUT_MINUTES = 240
class AuthSessionService:
def __init__(self, db: Session) -> None:
self.db = db
def issue(
self,
user: Any,
*,
metric_session_id: str,
timeout_minutes: int = DEFAULT_SESSION_TIMEOUT_MINUTES,
) -> tuple[str, AuthSession]:
now = datetime.now(UTC)
normalized_timeout = max(
MIN_SESSION_TIMEOUT_MINUTES,
min(
MAX_SESSION_TIMEOUT_MINUTES,
int(timeout_minutes or DEFAULT_SESSION_TIMEOUT_MINUTES),
),
)
access_token = secrets.token_urlsafe(32)
auth_session = AuthSession(
token_hash=self.hash_token(access_token),
tenant_id=str(getattr(user, "tenant_id", "default") or "default").strip() or "default",
principal_type="admin" if bool(getattr(user, "is_admin", False)) else "employee",
employee_id=str(getattr(user, "employee_id", "") or "").strip() or None,
username=str(getattr(user, "username", "") or "").strip(),
metric_session_id=str(metric_session_id or "").strip(),
issued_at=now,
expires_at=now + timedelta(minutes=normalized_timeout),
last_seen_at=now,
)
self.db.add(auth_session)
self.db.flush()
return access_token, auth_session
def authenticate(self, access_token: str) -> AuthSession | None:
normalized_token = str(access_token or "").strip()
if not normalized_token:
return None
auth_session = self.db.scalars(
select(AuthSession).where(AuthSession.token_hash == self.hash_token(normalized_token))
).first()
if auth_session is None or auth_session.revoked_at is not None:
return None
now = datetime.now(UTC)
if self._as_utc(auth_session.expires_at) <= now:
return None
auth_session.last_seen_at = now
return auth_session
def revoke(self, session_id: str, *, commit: bool = True) -> bool:
normalized_session_id = str(session_id or "").strip()
if not normalized_session_id:
return False
auth_session = self.db.get(AuthSession, normalized_session_id)
if auth_session is None:
return False
if auth_session.revoked_at is None:
auth_session.revoked_at = datetime.now(UTC)
if commit:
self.db.commit()
else:
self.db.flush()
return True
@staticmethod
def hash_token(access_token: str) -> str:
return hashlib.sha256(str(access_token or "").encode("utf-8")).hexdigest()
@staticmethod
def _as_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)

View File

@@ -1,8 +1,8 @@
from __future__ import annotations from __future__ import annotations
import threading
import uuid import uuid
from datetime import UTC, datetime from datetime import UTC, datetime
import threading
from typing import Any from typing import Any
from sqlalchemy import or_, select from sqlalchemy import or_, select
@@ -46,6 +46,7 @@ class UserSessionMetricService:
user: Any, user: Any,
*, *,
event: dict[str, Any] | None = None, event: dict[str, Any] | None = None,
commit: bool = True,
) -> UserSessionMetric: ) -> UserSessionMetric:
self.ensure_storage_ready() self.ensure_storage_ready()
now = datetime.now(UTC) now = datetime.now(UTC)
@@ -64,27 +65,36 @@ class UserSessionMetricService:
event_json=event or {}, event_json=event or {},
) )
self.db.add(session) self.db.add(session)
self.db.commit() if commit:
self.db.refresh(session) self.db.commit()
self.db.refresh(session)
else:
self.db.flush()
return session return session
def finish_session( def finish_session(
self, self,
*, *,
session_id: str, session_id: str,
expected_username: str = "",
reason: str = "manual", reason: str = "manual",
last_activity_at: datetime | None = None, last_activity_at: datetime | None = None,
activity_event_count: int = 0, activity_event_count: int = 0,
event: dict[str, Any] | None = None, event: dict[str, Any] | None = None,
commit: bool = True,
) -> UserSessionMetric | None: ) -> UserSessionMetric | None:
self.ensure_storage_ready() self.ensure_storage_ready()
normalized_session_id = str(session_id or "").strip() normalized_session_id = str(session_id or "").strip()
if not normalized_session_id: if not normalized_session_id:
return None return None
session = self.db.scalars( stmt = select(UserSessionMetric).where(
select(UserSessionMetric).where(UserSessionMetric.session_id == normalized_session_id) UserSessionMetric.session_id == normalized_session_id
).first() )
normalized_username = str(expected_username or "").strip()
if normalized_username:
stmt = stmt.where(UserSessionMetric.username == normalized_username)
session = self.db.scalars(stmt).first()
if session is None: if session is None:
return None return None
@@ -93,7 +103,11 @@ class UserSessionMetricService:
logout_at = datetime.now(UTC) logout_at = datetime.now(UTC)
session.logout_at = logout_at session.logout_at = logout_at
session.last_activity_at = self._normalize_last_activity(last_activity_at, session.login_at, logout_at) session.last_activity_at = self._normalize_last_activity(
last_activity_at,
session.login_at,
logout_at,
)
session.duration_ms = self._duration_ms(session.login_at, logout_at) session.duration_ms = self._duration_ms(session.login_at, logout_at)
session.activity_event_count = max(0, int(activity_event_count or 0)) session.activity_event_count = max(0, int(activity_event_count or 0))
session.logout_reason = str(reason or "manual").strip()[:40] or "manual" session.logout_reason = str(reason or "manual").strip()[:40] or "manual"
@@ -102,8 +116,11 @@ class UserSessionMetricService:
**(session.event_json or {}), **(session.event_json or {}),
"finish": event or {}, "finish": event or {},
} }
self.db.commit() if commit:
self.db.refresh(session) self.db.commit()
self.db.refresh(session)
else:
self.db.flush()
return session return session
def sum_duration_ms(self, identifiers: set[str], cutoff: datetime) -> int: def sum_duration_ms(self, identifiers: set[str], cutoff: datetime) -> int:

View File

@@ -0,0 +1,53 @@
from __future__ import annotations
from typing import Annotated
from fastapi import FastAPI, Header
from app.api.deps import CurrentUserContext, get_current_user
def install_legacy_header_auth_override(app: FastAPI) -> None:
"""让领域接口测试专注业务断言,不把旧请求头带回生产认证链路。"""
app.dependency_overrides[get_current_user] = _read_test_user_headers
def _read_test_user_headers(
username: Annotated[str | None, Header(alias="X-Auth-Username")] = None,
name: Annotated[str | None, Header(alias="X-Auth-Name")] = None,
role_codes: Annotated[str | None, Header(alias="X-Auth-Role-Codes")] = None,
is_admin: Annotated[str | None, Header(alias="X-Auth-Is-Admin")] = None,
department: Annotated[str | None, Header(alias="X-Auth-Department")] = None,
cost_center: Annotated[str | None, Header(alias="X-Auth-Cost-Center")] = None,
position: Annotated[str | None, Header(alias="X-Auth-Position")] = None,
grade: Annotated[str | None, Header(alias="X-Auth-Grade")] = None,
employee_no: Annotated[str | None, Header(alias="X-Auth-Employee-No")] = None,
manager_name: Annotated[str | None, Header(alias="X-Auth-Manager-Name")] = None,
) -> CurrentUserContext:
normalized_username = str(username or "").strip()
normalized_name = str(name or normalized_username).strip()
if not normalized_username and not normalized_name:
normalized_username = "test-admin"
normalized_name = "Test Admin"
is_admin = "true"
normalized_roles = [
normalized
for item in str(role_codes or "").split(",")
if (normalized := item.strip().lower())
]
admin_flag = str(is_admin or "").strip().lower() in {"1", "true", "yes", "on"}
admin_flag = admin_flag or normalized_username.lower() in {"admin", "superadmin"}
admin_flag = admin_flag or bool(set(normalized_roles) & {"admin", "superadmin"})
return CurrentUserContext(
username=normalized_username or normalized_name,
name=normalized_name or normalized_username,
role_codes=normalized_roles,
is_admin=admin_flag,
department_name=str(department or "").strip(),
cost_center=str(cost_center or "").strip(),
position=str(position or "").strip(),
grade=str(grade or "").strip(),
employee_no=str(employee_no or "").strip(),
manager_name=str(manager_name or "").strip(),
)

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from collections.abc import Generator from collections.abc import Generator
from auth_helpers import install_legacy_header_auth_override
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
@@ -24,6 +25,7 @@ def build_client() -> tuple[TestClient, sessionmaker[Session]]:
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False) session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
app = create_app() app = create_app()
install_legacy_header_auth_override(app)
def override_db() -> Generator[Session, None, None]: def override_db() -> Generator[Session, None, None]:
db = session_factory() db = session_factory()

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
from collections.abc import Generator from collections.abc import Generator
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from auth_helpers import install_legacy_header_auth_override
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
@@ -37,6 +38,7 @@ def build_client() -> tuple[TestClient, sessionmaker[Session]]:
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False) session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
app = create_app() app = create_app()
install_legacy_header_auth_override(app)
def override_db() -> Generator[Session, None, None]: def override_db() -> Generator[Session, None, None]:
db = session_factory() db = session_factory()

View File

@@ -5,6 +5,7 @@ from collections.abc import Generator
from datetime import UTC, date, datetime from datetime import UTC, date, datetime
from decimal import Decimal from decimal import Decimal
from auth_helpers import install_legacy_header_auth_override
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload from sqlalchemy.orm import Session, selectinload
@@ -17,8 +18,8 @@ from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead, OcrRecognizeFieldRead from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead, OcrRecognizeFieldRead
from app.services.attachment_association_jobs import clear_attachment_association_jobs_for_tests from app.services.attachment_association_jobs import clear_attachment_association_jobs_for_tests
from app.services.expense_claims import ExpenseClaimService
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
from app.services.expense_claims import ExpenseClaimService
from app.services.ocr import OcrService from app.services.ocr import OcrService
from app.services.receipt_folder import ReceiptFolderService from app.services.receipt_folder import ReceiptFolderService
from app.test_helpers.db import build_in_memory_session_factory from app.test_helpers.db import build_in_memory_session_factory
@@ -27,6 +28,7 @@ from app.test_helpers.db import build_in_memory_session_factory
def build_client(monkeypatch) -> tuple[TestClient, object]: def build_client(monkeypatch) -> tuple[TestClient, object]:
session_factory = build_in_memory_session_factory() session_factory = build_in_memory_session_factory()
app = create_app() app = create_app()
install_legacy_header_auth_override(app)
def override_db() -> Generator[Session, None, None]: def override_db() -> Generator[Session, None, None]:
db = session_factory() db = session_factory()

View File

@@ -1,13 +1,17 @@
from __future__ import annotations from __future__ import annotations
import pytest
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool from sqlalchemy.pool import StaticPool
from app.db.base import Base from app.db.base import Base
from app.models.auth_session import AuthSession
from app.models.user_session_metric import UserSessionMetric
from app.schemas.auth import LoginRequest from app.schemas.auth import LoginRequest
from app.schemas.settings import SettingsWrite from app.schemas.settings import SettingsWrite
from app.services.auth import AuthService, AuthenticatedUser from app.services.auth import AuthenticatedUser, AuthService
from app.services.auth_sessions import AuthSessionService
from app.services.employee import EmployeeService from app.services.employee import EmployeeService
from app.services.settings import SettingsService from app.services.settings import SettingsService
@@ -37,6 +41,11 @@ def test_employee_can_login_with_seed_default_password() -> None:
assert result.user.grade == employee.grade assert result.user.grade == employee.grade
assert result.user.roleCodes assert result.user.roleCodes
assert result.user.isAdmin is False assert result.user.isAdmin is False
assert result.accessToken
assert result.tokenType == "Bearer"
stored_session = db.query(AuthSession).one()
assert stored_session.token_hash == AuthSessionService.hash_token(result.accessToken)
assert stored_session.token_hash != result.accessToken
def test_current_user_snapshot_refreshes_employee_position() -> None: def test_current_user_snapshot_refreshes_employee_position() -> None:
@@ -113,8 +122,15 @@ def test_employee_login_skips_directory_bootstrap_when_employee_exists(monkeypat
calls.append("ensure_directory_ready") calls.append("ensure_directory_ready")
raise AssertionError("existing employee login should not run directory bootstrap") raise AssertionError("existing employee login should not run directory bootstrap")
monkeypatch.setattr(AuthService, "_find_employee_by_email", lambda self, _: ExistingEmployee()) monkeypatch.setattr(
monkeypatch.setattr("app.services.auth.verify_password", lambda password, password_hash: True) AuthService,
"_find_employee_by_email",
lambda self, _: ExistingEmployee(),
)
monkeypatch.setattr(
"app.services.auth.verify_password",
lambda password, password_hash: True,
)
monkeypatch.setattr( monkeypatch.setattr(
AuthService, AuthService,
"_build_employee_user", "_build_employee_user",
@@ -143,3 +159,35 @@ def test_employee_login_skips_directory_bootstrap_when_employee_exists(monkeypat
assert user is not None assert user is not None
assert user.username == "demo@example.com" assert user.username == "demo@example.com"
assert calls == [] assert calls == []
def test_login_session_write_rolls_back_metric_when_token_issue_fails(monkeypatch) -> None:
with build_session() as db:
user = AuthenticatedUser(
username="rollback@example.com",
name="Rollback User",
role="使用者",
department="",
position="",
grade="",
employee_no="",
manager_name="",
location="",
cost_center="",
finance_owner_name="",
risk_profile={},
role_codes=["user"],
email="rollback@example.com",
avatar="R",
)
def fail_issue(*args, **kwargs):
raise RuntimeError("token issue failed")
monkeypatch.setattr(AuthSessionService, "issue", fail_issue)
with pytest.raises(RuntimeError, match="token issue failed"):
AuthService(db)._build_login_response(user)
assert db.query(AuthSession).count() == 0
assert db.query(UserSessionMetric).count() == 0

View File

@@ -0,0 +1,148 @@
from __future__ import annotations
from collections.abc import Generator
from datetime import UTC, datetime, timedelta
import pytest
from fastapi import HTTPException
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.api.deps import CurrentUserContext, get_db, require_platform_admin_user
from app.db.base import Base
from app.main import create_app
from app.models.auth_session import AuthSession
from app.models.user_session_metric import UserSessionMetric
from app.schemas.settings import SettingsWrite
from app.services.auth_sessions import AuthSessionService
from app.services.settings import SettingsService
def build_client() -> tuple[TestClient, sessionmaker[Session]]:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
with session_factory() as db:
settings_service = SettingsService(db)
payload = settings_service.get_settings_snapshot().model_dump()
payload["adminForm"]["adminAccount"] = "auth-admin"
payload["adminForm"]["newPassword"] = "safe-admin-password"
payload["adminForm"]["confirmPassword"] = "safe-admin-password"
settings_service.save_settings_snapshot(SettingsWrite(**payload))
app = create_app()
def override_db() -> Generator[Session, None, None]:
with session_factory() as db:
yield db
app.dependency_overrides[get_db] = override_db
return TestClient(app), session_factory
def login_admin(client: TestClient) -> dict:
response = client.post(
"/api/v1/auth/login",
json={"username": "auth-admin", "password": "safe-admin-password"},
)
assert response.status_code == 200
return response.json()
def test_login_issues_opaque_bearer_token_and_me_uses_server_session() -> None:
client, session_factory = build_client()
payload = login_admin(client)
token = payload["accessToken"]
assert payload["tokenType"] == "Bearer"
assert payload["expiresAt"]
with session_factory() as db:
auth_session = db.scalars(select(AuthSession)).one()
assert auth_session.token_hash == AuthSessionService.hash_token(token)
assert auth_session.token_hash != token
response = client.get(
"/api/v1/auth/me",
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 200
assert response.json()["username"] == "auth-admin"
assert response.json()["isAdmin"] is True
def test_forged_identity_headers_no_longer_authenticate() -> None:
client, _ = build_client()
response = client.get(
"/api/v1/auth/me",
headers={
"X-Auth-Username": "superadmin",
"X-Auth-Role-Codes": "manager",
"X-Auth-Is-Admin": "true",
},
)
assert response.status_code == 401
assert response.headers["www-authenticate"] == "Bearer"
def test_expired_and_revoked_tokens_are_rejected() -> None:
client, session_factory = build_client()
first_payload = login_admin(client)
first_token = first_payload["accessToken"]
with session_factory() as db:
auth_session = db.scalars(
select(AuthSession).where(
AuthSession.token_hash == AuthSessionService.hash_token(first_token)
)
).one()
auth_session.expires_at = datetime.now(UTC) - timedelta(seconds=1)
db.commit()
expired_response = client.get(
"/api/v1/auth/me",
headers={"Authorization": f"Bearer {first_token}"},
)
assert expired_response.status_code == 401
second_payload = login_admin(client)
second_token = second_payload["accessToken"]
logout_response = client.post(
"/api/v1/auth/logout",
headers={"Authorization": f"Bearer {second_token}"},
json={"sessionId": second_payload["sessionId"], "reason": "manual"},
)
assert logout_response.status_code == 200
with session_factory() as db:
metric_session = db.scalars(
select(UserSessionMetric).where(
UserSessionMetric.session_id == second_payload["sessionId"]
)
).one()
assert metric_session.status == "closed"
revoked_response = client.get(
"/api/v1/auth/me",
headers={"Authorization": f"Bearer {second_token}"},
)
assert revoked_response.status_code == 401
def test_manager_role_is_not_platform_admin() -> None:
manager = CurrentUserContext(
username="manager@example.com",
name="Manager",
role_codes=["manager"],
is_admin=False,
)
with pytest.raises(HTTPException) as exc_info:
require_platform_admin_user(manager)
assert exc_info.value.status_code == 403

View File

@@ -4,6 +4,7 @@ from collections.abc import Generator
from datetime import UTC, datetime from datetime import UTC, datetime
from decimal import Decimal from decimal import Decimal
from auth_helpers import install_legacy_header_auth_override
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
@@ -25,6 +26,7 @@ def build_client() -> tuple[TestClient, sessionmaker[Session]]:
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False) session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
app = create_app() app = create_app()
install_legacy_header_auth_override(app)
def override_db() -> Generator[Session, None, None]: def override_db() -> Generator[Session, None, None]:
db = session_factory() db = session_factory()
@@ -112,7 +114,10 @@ def test_expense_claims_support_page_envelope_and_keep_legacy_list() -> None:
def test_employee_directory_supports_backend_pagination() -> None: def test_employee_directory_supports_backend_pagination() -> None:
client, _ = build_client() client, _ = build_client()
response = client.get("/api/v1/employees?page=2&page_size=10") response = client.get(
"/api/v1/employees?page=2&page_size=10",
headers={"x-auth-username": "admin"},
)
assert response.status_code == 200 assert response.status_code == 200
payload = response.json() payload = response.json()

View File

@@ -0,0 +1,65 @@
from __future__ import annotations
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from app.api.v1.endpoints import bootstrap as bootstrap_endpoint
from app.schemas.bootstrap import BootstrapSetupPayload
def completed_settings() -> SimpleNamespace:
return SimpleNamespace(
setup_completed=True,
company_name="X-Financial",
company_code="XF-001",
admin_email="admin@example.com",
web_host="0.0.0.0",
web_port=5273,
app_host="0.0.0.0",
app_port=8000,
postgres_host="postgres.internal",
postgres_port=5432,
postgres_db="x_financial",
postgres_user="postgres-admin",
postgres_password="secret",
redis_url="redis://redis.internal:6379/0",
)
def setup_payload() -> BootstrapSetupPayload:
return BootstrapSetupPayload(
company_name="X-Financial",
company_code="XF-001",
admin_email="admin@example.com",
postgres_host="postgres.internal",
postgres_port=5432,
postgres_db="x_financial",
postgres_user="postgres-admin",
postgres_password="secret",
redis_url="redis://redis.internal:6379/0",
)
def test_completed_bootstrap_state_redacts_infrastructure(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(bootstrap_endpoint, "get_settings", completed_settings)
state = bootstrap_endpoint.get_bootstrap_state()
assert state.initialized is True
assert state.database.host == ""
assert state.database.username == ""
assert state.database.password_configured is True
assert state.redis.url == ""
def test_completed_bootstrap_rejects_anonymous_reconfiguration(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(bootstrap_endpoint, "get_settings", completed_settings)
with pytest.raises(HTTPException) as exc_info:
bootstrap_endpoint.initialize_bootstrap(setup_payload(), None)
assert exc_info.value.status_code == 403

View File

@@ -4,6 +4,7 @@ from collections.abc import Generator
from datetime import UTC, datetime from datetime import UTC, datetime
from decimal import Decimal from decimal import Decimal
from auth_helpers import install_legacy_header_auth_override
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
@@ -32,6 +33,7 @@ def build_session_factory() -> sessionmaker[Session]:
def build_client() -> tuple[TestClient, sessionmaker[Session]]: def build_client() -> tuple[TestClient, sessionmaker[Session]]:
session_factory = build_session_factory() session_factory = build_session_factory()
app = create_app() app = create_app()
install_legacy_header_auth_override(app)
def override_db() -> Generator[Session, None, None]: def override_db() -> Generator[Session, None, None]:
db = session_factory() db = session_factory()

View File

@@ -4,6 +4,7 @@ from collections.abc import Generator
from datetime import UTC, date, datetime, timedelta from datetime import UTC, date, datetime, timedelta
from decimal import Decimal from decimal import Decimal
from auth_helpers import install_legacy_header_auth_override
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
@@ -249,6 +250,7 @@ def test_latest_profile_endpoint_returns_approval_payload() -> None:
seed_profile_data(db) seed_profile_data(db)
app = create_app() app = create_app()
install_legacy_header_auth_override(app)
def override_db() -> Generator[Session, None, None]: def override_db() -> Generator[Session, None, None]:
db = session_factory() db = session_factory()
@@ -303,6 +305,7 @@ def test_current_employee_profile_endpoint_resolves_login_user() -> None:
) )
app = create_app() app = create_app()
install_legacy_header_auth_override(app)
def override_db() -> Generator[Session, None, None]: def override_db() -> Generator[Session, None, None]:
db = session_factory() db = session_factory()
@@ -370,6 +373,7 @@ def test_current_admin_profile_endpoint_returns_account_usage_profile() -> None:
db.commit() db.commit()
app = create_app() app = create_app()
install_legacy_header_auth_override(app)
def override_db() -> Generator[Session, None, None]: def override_db() -> Generator[Session, None, None]:
db = session_factory() db = session_factory()
@@ -414,6 +418,7 @@ def test_current_admin_profile_endpoint_uses_online_session_without_agent_runs()
) )
app = create_app() app = create_app()
install_legacy_header_auth_override(app)
def override_db() -> Generator[Session, None, None]: def override_db() -> Generator[Session, None, None]:
db = session_factory() db = session_factory()
@@ -464,6 +469,7 @@ def test_finish_session_endpoint_closes_active_session() -> None:
db.commit() db.commit()
app = create_app() app = create_app()
install_legacy_header_auth_override(app)
def override_db() -> Generator[Session, None, None]: def override_db() -> Generator[Session, None, None]:
db = session_factory() db = session_factory()

View File

@@ -137,7 +137,12 @@ def test_legacy_bootstrap_excludes_migration_owned_tables(
table_names = set(inspect(engine).get_table_names()) table_names = set(inspect(engine).get_table_names())
assert "employees" in table_names assert "employees" in table_names
assert {"expense_cases", "expense_case_links", "business_events"}.isdisjoint(table_names) assert {
"auth_sessions",
"expense_cases",
"expense_case_links",
"business_events",
}.isdisjoint(table_names)
def test_event_write_is_idempotent_for_same_business_operation() -> None: def test_event_write_is_idempotent_for_same_business_operation() -> None:

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
from collections.abc import Generator from collections.abc import Generator
from datetime import UTC, datetime from datetime import UTC, datetime
from auth_helpers import install_legacy_header_auth_override
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -12,7 +13,9 @@ from app.main import create_app
from app.models.employee import Employee from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim from app.models.financial_record import ExpenseClaim
from app.schemas.orchestrator import OrchestratorResponse, OrchestratorTraceSummary from app.schemas.orchestrator import OrchestratorResponse, OrchestratorTraceSummary
from app.services.linked_reimbursement_draft_jobs import clear_linked_reimbursement_draft_jobs_for_tests from app.services.linked_reimbursement_draft_jobs import (
clear_linked_reimbursement_draft_jobs_for_tests,
)
from app.services.orchestrator import OrchestratorService from app.services.orchestrator import OrchestratorService
from app.test_helpers.db import build_in_memory_session_factory from app.test_helpers.db import build_in_memory_session_factory
@@ -53,6 +56,7 @@ def seed_employee_and_application(db: Session) -> None:
def build_client(monkeypatch) -> tuple[TestClient, object]: def build_client(monkeypatch) -> tuple[TestClient, object]:
session_factory = build_in_memory_session_factory() session_factory = build_in_memory_session_factory()
app = create_app() app = create_app()
install_legacy_header_auth_override(app)
def override_db() -> Generator[Session, None, None]: def override_db() -> Generator[Session, None, None]:
db = session_factory() db = session_factory()

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from collections.abc import Generator from collections.abc import Generator
from auth_helpers import install_legacy_header_auth_override
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
@@ -34,6 +35,7 @@ def build_client() -> TestClient:
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False) session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
app = create_app() app = create_app()
install_legacy_header_auth_override(app)
def override_db() -> Generator[Session, None, None]: def override_db() -> Generator[Session, None, None]:
db = session_factory() db = session_factory()

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from collections.abc import Generator from collections.abc import Generator
from auth_helpers import install_legacy_header_auth_override
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
@@ -11,7 +12,12 @@ from app.api.deps import get_db
from app.core.config import get_settings from app.core.config import get_settings
from app.db.base import Base from app.db.base import Base
from app.main import create_app from app.main import create_app
from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead, OcrRecognizeFieldRead, OcrRecognizeLineRead from app.schemas.ocr import (
OcrRecognizeBatchRead,
OcrRecognizeDocumentRead,
OcrRecognizeFieldRead,
OcrRecognizeLineRead,
)
from app.services.ocr import OcrService from app.services.ocr import OcrService
@@ -24,6 +30,7 @@ def build_client() -> TestClient:
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False) session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
app = create_app() app = create_app()
install_legacy_header_auth_override(app)
def override_db() -> Generator[Session, None, None]: def override_db() -> Generator[Session, None, None]:
db = session_factory() db = session_factory()

View File

@@ -3,13 +3,14 @@ from __future__ import annotations
from collections.abc import Generator from collections.abc import Generator
import pytest import pytest
from auth_helpers import install_legacy_header_auth_override
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool from sqlalchemy.pool import StaticPool
from app.core.agent_enums import AgentName, AgentRunSource, AgentRunStatus
from app.api.deps import get_db from app.api.deps import get_db
from app.core.agent_enums import AgentName, AgentRunSource, AgentRunStatus
from app.db.base import Base from app.db.base import Base
from app.schemas.ontology import OntologyParseRequest from app.schemas.ontology import OntologyParseRequest
from app.services.ontology import LlmOntologyParseResult, SemanticOntologyService from app.services.ontology import LlmOntologyParseResult, SemanticOntologyService
@@ -32,6 +33,7 @@ def build_client() -> tuple[TestClient, sessionmaker[Session]]:
from app.main import create_app from app.main import create_app
app = create_app() app = create_app()
install_legacy_header_auth_override(app)
def override_db() -> Generator[Session, None, None]: def override_db() -> Generator[Session, None, None]:
db = session_factory() db = session_factory()

View File

@@ -6,6 +6,7 @@ from collections.abc import Generator
from datetime import UTC, date, datetime from datetime import UTC, date, datetime
from decimal import Decimal from decimal import Decimal
from auth_helpers import install_legacy_header_auth_override
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
@@ -38,6 +39,7 @@ def build_session_factory() -> sessionmaker[Session]:
def build_client() -> tuple[TestClient, sessionmaker[Session]]: def build_client() -> tuple[TestClient, sessionmaker[Session]]:
session_factory = build_session_factory() session_factory = build_session_factory()
app = create_app() app = create_app()
install_legacy_header_auth_override(app)
def override_db() -> Generator[Session, None, None]: def override_db() -> Generator[Session, None, None]:
db = session_factory() db = session_factory()

View File

@@ -5,12 +5,14 @@ from datetime import UTC, datetime
from decimal import Decimal from decimal import Decimal
import pytest import pytest
from auth_helpers import install_legacy_header_auth_override
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool from sqlalchemy.pool import StaticPool
from app.algorithem.risk_graph.replay import AlgorithmReplaySetBuilder
from app.api.deps import get_db from app.api.deps import get_db
from app.api.v1.endpoints.risk_observations import router as risk_observations_router from app.api.v1.endpoints.risk_observations import router as risk_observations_router
from app.db.base import Base from app.db.base import Base
@@ -18,7 +20,6 @@ from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim from app.models.financial_record import ExpenseClaim
from app.models.risk_observation import RiskObservation from app.models.risk_observation import RiskObservation
from app.schemas.risk_observation import RiskObservationFeedbackCreate from app.schemas.risk_observation import RiskObservationFeedbackCreate
from app.algorithem.risk_graph.replay import AlgorithmReplaySetBuilder
from app.services.risk_observations import RiskObservationService from app.services.risk_observations import RiskObservationService
@@ -266,6 +267,7 @@ def _build_client() -> tuple[TestClient, sessionmaker[Session]]:
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False) session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
app = FastAPI() app = FastAPI()
app.include_router(risk_observations_router, prefix="/api/v1") app.include_router(risk_observations_router, prefix="/api/v1")
install_legacy_header_auth_override(app)
def override_db() -> Generator[Session, None, None]: def override_db() -> Generator[Session, None, None]:
db = session_factory() db = session_factory()

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from collections.abc import Generator from collections.abc import Generator
from auth_helpers import install_legacy_header_auth_override
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy import create_engine, select from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
@@ -45,6 +46,7 @@ def build_client() -> tuple[TestClient, sessionmaker[Session]]:
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False) session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
app = create_app() app = create_app()
install_legacy_header_auth_override(app)
def override_db() -> Generator[Session, None, None]: def override_db() -> Generator[Session, None, None]:
db = session_factory() db = session_factory()

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from collections.abc import Generator from collections.abc import Generator
from auth_helpers import install_legacy_header_auth_override
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
@@ -13,8 +14,8 @@ from app.db.base import Base
from app.main import create_app from app.main import create_app
from app.models.agent_asset import AgentAsset from app.models.agent_asset import AgentAsset
from app.schemas.agent_asset import AgentAssetRiskRuleGenerateRequest from app.schemas.agent_asset import AgentAssetRiskRuleGenerateRequest
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
from app.services.agent_asset_risk_rule_regeneration import AgentAssetRiskRuleRegenerationService from app.services.agent_asset_risk_rule_regeneration import AgentAssetRiskRuleRegenerationService
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
from app.services.agent_assets import AgentAssetService from app.services.agent_assets import AgentAssetService
from app.services.risk_rule_generation import RiskRuleGenerationService from app.services.risk_rule_generation import RiskRuleGenerationService
@@ -33,6 +34,7 @@ def build_client() -> tuple[TestClient, sessionmaker[Session]]:
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False) session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
app = create_app() app = create_app()
install_legacy_header_auth_override(app)
def override_db() -> Generator[Session, None, None]: def override_db() -> Generator[Session, None, None]:
db = session_factory() db = session_factory()

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from auth_helpers import install_legacy_header_auth_override
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app.main import create_app from app.main import create_app
@@ -49,11 +50,13 @@ def test_risk_rule_template_catalog_groups_and_dsl_examples() -> None:
def test_risk_rule_template_endpoint_requires_login_and_returns_groups() -> None: def test_risk_rule_template_endpoint_requires_login_and_returns_groups() -> None:
client = TestClient(create_app()) app = create_app()
client = TestClient(app)
unauthorized = client.get("/api/v1/agent-assets/risk-rules/templates") unauthorized = client.get("/api/v1/agent-assets/risk-rules/templates")
assert unauthorized.status_code == 401 assert unauthorized.status_code == 401
install_legacy_header_auth_override(app)
response = client.get( response = client.get(
"/api/v1/agent-assets/risk-rules/templates", "/api/v1/agent-assets/risk-rules/templates",
headers={"x-auth-username": "finance", "x-auth-role-codes": "finance"}, headers={"x-auth-username": "finance", "x-auth-role-codes": "finance"},

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
from collections.abc import Generator from collections.abc import Generator
from datetime import UTC, datetime from datetime import UTC, datetime
from auth_helpers import install_legacy_header_auth_override
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy import create_engine, select from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
@@ -30,6 +31,7 @@ def build_session_factory() -> sessionmaker[Session]:
def build_client() -> tuple[TestClient, sessionmaker[Session]]: def build_client() -> tuple[TestClient, sessionmaker[Session]]:
session_factory = build_session_factory() session_factory = build_session_factory()
app = create_app() app = create_app()
install_legacy_header_auth_override(app)
def override_db() -> Generator[Session, None, None]: def override_db() -> Generator[Session, None, None]:
db = session_factory() db = session_factory()

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from collections.abc import Generator from collections.abc import Generator
from auth_helpers import install_legacy_header_auth_override
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
@@ -23,6 +24,7 @@ def build_client() -> TestClient:
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False) session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
app = create_app() app = create_app()
install_legacy_header_auth_override(app)
def override_db() -> Generator[Session, None, None]: def override_db() -> Generator[Session, None, None]:
db = session_factory() db = session_factory()

View File

@@ -8,7 +8,10 @@ import {
testBootstrapDatabase, testBootstrapDatabase,
testBootstrapRuntime testBootstrapRuntime
} from '../services/bootstrap.js' } from '../services/bootstrap.js'
import { fetchCurrentAuthUser, login as loginByAccount } from '../services/auth.js' import {
fetchCurrentAuthUser,
login as loginByAccount
} from '../services/auth.js'
import { setRuntimeApiBaseUrl } from '../services/api.js' import { setRuntimeApiBaseUrl } from '../services/api.js'
import { checkBackendHealth } from './useBackendHealth.js' import { checkBackendHealth } from './useBackendHealth.js'
import { resolveDefaultAuthorizedRoute } from '../utils/accessControl.js' import { resolveDefaultAuthorizedRoute } from '../utils/accessControl.js'
@@ -16,15 +19,22 @@ import { useToast } from './useToast.js'
import { fetchSettings } from '../services/settings.js' import { fetchSettings } from '../services/settings.js'
import { setThemeSkin } from './useThemeSkin.js' import { setThemeSkin } from './useThemeSkin.js'
import { normalizeAuthUserSnapshot, resolveAuthUserAdminFlag } from '../utils/authUser.js' import { normalizeAuthUserSnapshot, resolveAuthUserAdminFlag } from '../utils/authUser.js'
import {
AUTH_SESSION_EXPIRED_EVENT,
clearAuthCredentials,
hasValidAuthSession,
persistAuthCredentials,
readAuthAccessToken,
readAuthExpiresAt
} from '../utils/authSessionStorage.js'
import { import {
clearAuthSessionMetrics, clearAuthSessionMetrics,
finalizeAndRevokeAuthSession,
finalizeAuthSession, finalizeAuthSession,
incrementAuthActivityCount, incrementAuthActivityCount,
persistAuthSessionMetrics persistAuthSessionMetrics
} from '../utils/authSessionMetrics.js' } from '../utils/authSessionMetrics.js'
const AUTH_STORAGE_KEY = 'x-financial-authenticated'
const AUTH_USERNAME_KEY = 'x-financial-auth-username'
const AUTH_USER_KEY = 'x-financial-auth-user' const AUTH_USER_KEY = 'x-financial-auth-user'
const AUTH_LAST_ACTIVITY_KEY = 'x-financial-auth-last-activity' const AUTH_LAST_ACTIVITY_KEY = 'x-financial-auth-last-activity'
const DEFAULT_USER_NAME = '系统管理员' const DEFAULT_USER_NAME = '系统管理员'
@@ -79,19 +89,7 @@ function readClientBootstrapState() {
} }
function readAuthState() { function readAuthState() {
if (typeof window === 'undefined') { return hasValidAuthSession()
return false
}
return window.sessionStorage.getItem(AUTH_STORAGE_KEY) === 'true'
}
function readStoredUsername() {
if (typeof window === 'undefined') {
return ''
}
return window.sessionStorage.getItem(AUTH_USERNAME_KEY) || ''
} }
function buildAnonymousUser() { function buildAnonymousUser() {
@@ -116,31 +114,6 @@ function buildAnonymousUser() {
} }
} }
function buildLegacyAdminUser(username = '') {
const normalized = String(username || '').trim()
const name = normalized || DEFAULT_USER_NAME
return {
username: normalized,
name,
role: DEFAULT_USER_ROLE,
department: '',
departmentName: '',
position: DEFAULT_USER_ROLE,
grade: '',
employeeNo: '',
managerName: '',
location: '',
costCenter: '',
financeOwnerName: '',
riskProfile: {},
roleCodes: ['manager'],
email: '',
avatar: name.slice(0, 1).toUpperCase(),
isAdmin: true
}
}
function resolvePlatformAdminFlag(payload, roleCodes = []) { function resolvePlatformAdminFlag(payload, roleCodes = []) {
return resolveAuthUserAdminFlag(payload, roleCodes) return resolveAuthUserAdminFlag(payload, roleCodes)
} }
@@ -171,12 +144,11 @@ function readStoredUser() {
return normalizeStoredAuthUser(payload) return normalizeStoredAuthUser(payload)
} }
} catch { } catch {
return buildLegacyAdminUser(readStoredUsername()) return buildAnonymousUser()
} }
} }
const legacyUsername = readStoredUsername() return buildAnonymousUser()
return legacyUsername ? buildLegacyAdminUser(legacyUsername) : buildAnonymousUser()
} }
function readLastActivityAt() { function readLastActivityAt() {
@@ -188,10 +160,14 @@ function readLastActivityAt() {
} }
function isSessionExpired(now = Date.now()) { function isSessionExpired(now = Date.now()) {
if (!readAuthState()) { if (!readAuthAccessToken()) {
return false return false
} }
if (!hasValidAuthSession(now)) {
return true
}
const lastActivityAt = readLastActivityAt() const lastActivityAt = readLastActivityAt()
if (!lastActivityAt) { if (!lastActivityAt) {
@@ -201,24 +177,24 @@ function isSessionExpired(now = Date.now()) {
return now - lastActivityAt > authIdleTimeoutMs return now - lastActivityAt > authIdleTimeoutMs
} }
function persistAuthState(value, user = null, sessionId = '') { function persistAuthState(value, user = null, sessionId = '', accessToken = '', expiresAt = '') {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return return
} }
if (value) { if (value) {
window.sessionStorage.setItem(AUTH_STORAGE_KEY, 'true')
const normalizedUser = user || buildAnonymousUser() const normalizedUser = user || buildAnonymousUser()
window.sessionStorage.setItem(AUTH_USERNAME_KEY, String(normalizedUser.username || '').trim())
window.sessionStorage.setItem(AUTH_USER_KEY, JSON.stringify(normalizedUser)) window.sessionStorage.setItem(AUTH_USER_KEY, JSON.stringify(normalizedUser))
persistAuthCredentials(accessToken, expiresAt)
persistAuthSessionMetrics(sessionId) persistAuthSessionMetrics(sessionId)
return return
} }
window.sessionStorage.removeItem(AUTH_STORAGE_KEY)
window.sessionStorage.removeItem(AUTH_USERNAME_KEY)
window.sessionStorage.removeItem(AUTH_USER_KEY) window.sessionStorage.removeItem(AUTH_USER_KEY)
window.sessionStorage.removeItem(AUTH_LAST_ACTIVITY_KEY) window.sessionStorage.removeItem(AUTH_LAST_ACTIVITY_KEY)
window.sessionStorage.removeItem('x-financial-authenticated')
window.sessionStorage.removeItem('x-financial-auth-username')
clearAuthCredentials()
clearAuthSessionMetrics() clearAuthSessionMetrics()
} }
@@ -228,7 +204,6 @@ function persistAuthUserSnapshot(user = {}) {
} }
const normalizedUser = user || buildAnonymousUser() const normalizedUser = user || buildAnonymousUser()
window.sessionStorage.setItem(AUTH_USERNAME_KEY, String(normalizedUser.username || '').trim())
window.sessionStorage.setItem(AUTH_USER_KEY, JSON.stringify(normalizedUser)) window.sessionStorage.setItem(AUTH_USER_KEY, JSON.stringify(normalizedUser))
} }
@@ -269,7 +244,9 @@ function scheduleSessionTimeout() {
return return
} }
const remaining = authIdleTimeoutMs - (Date.now() - lastActivityAt) const idleRemaining = authIdleTimeoutMs - (Date.now() - lastActivityAt)
const tokenRemaining = readAuthExpiresAt() - Date.now()
const remaining = Math.min(idleRemaining, tokenRemaining)
if (remaining <= 0) { if (remaining <= 0) {
logout('timeout', { notify: true }) logout('timeout', { notify: true })
@@ -307,6 +284,13 @@ function handleSessionActivity(event) {
touchAuthActivity() touchAuthActivity()
} }
function handleAuthSessionExpired() {
if (!readAuthAccessToken()) {
return
}
logout('expired', { notify: true, revoke: false })
}
function handleSessionUnload(event) { function handleSessionUnload(event) {
if (event?.type === 'pagehide' && event.persisted) { if (event?.type === 'pagehide' && event.persisted) {
return return
@@ -323,6 +307,7 @@ function installSessionMonitoring() {
SESSION_ACTIVITY_EVENTS.forEach((eventName) => { SESSION_ACTIVITY_EVENTS.forEach((eventName) => {
window.addEventListener(eventName, handleSessionActivity, { passive: true }) window.addEventListener(eventName, handleSessionActivity, { passive: true })
}) })
window.addEventListener(AUTH_SESSION_EXPIRED_EVENT, handleAuthSessionExpired)
window.addEventListener('pagehide', handleSessionUnload, { passive: true }) window.addEventListener('pagehide', handleSessionUnload, { passive: true })
window.addEventListener('beforeunload', handleSessionUnload, { passive: true }) window.addEventListener('beforeunload', handleSessionUnload, { passive: true })
} }
@@ -431,7 +416,7 @@ const loginError = ref('')
const loggedIn = ref(readAuthState() && !isSessionExpired()) const loggedIn = ref(readAuthState() && !isSessionExpired())
const currentUser = ref(readStoredUser()) const currentUser = ref(readStoredUser())
if (!loggedIn.value && readAuthState()) { if (!loggedIn.value && readAuthAccessToken()) {
persistAuthState(false) persistAuthState(false)
} }
@@ -674,7 +659,13 @@ async function handleLogin(credentials) {
isAdmin: resolvePlatformAdminFlag(responseUser, responseRoleCodes) isAdmin: resolvePlatformAdminFlag(responseUser, responseRoleCodes)
} }
loggedIn.value = true loggedIn.value = true
persistAuthState(true, user, response?.sessionId || '') persistAuthState(
true,
user,
response?.sessionId || '',
response?.accessToken || '',
response?.expiresAt || ''
)
currentUser.value = user currentUser.value = user
touchAuthActivity(true) touchAuthActivity(true)
return true return true
@@ -691,15 +682,24 @@ async function handleLogin(credentials) {
function logout(reason = 'manual', options = {}) { function logout(reason = 'manual', options = {}) {
const notify = options.notify ?? reason === 'timeout' const notify = options.notify ?? reason === 'timeout'
const redirect = options.redirect ?? reason !== 'invalid' const redirect = options.redirect ?? reason !== 'invalid'
const revoke = options.revoke ?? (reason !== 'invalid' && reason !== 'expired')
finalizeAuthSession(reason) if (revoke && readAuthAccessToken()) {
finalizeAndRevokeAuthSession(reason).catch((error) => {
console.warn('Failed to revoke auth session:', error)
})
}
loggedIn.value = false loggedIn.value = false
persistAuthState(false) persistAuthState(false)
currentUser.value = buildAnonymousUser() currentUser.value = buildAnonymousUser()
clearSessionTimeout() clearSessionTimeout()
if (notify) { if (notify) {
toast(reason === 'timeout' ? '登录已超时,请重新登录。' : '已退出登录。') toast(
reason === 'timeout' || reason === 'expired'
? '登录已失效,请重新登录。'
: '已退出登录。'
)
} }
if (redirect) { if (redirect) {

View File

@@ -1,7 +1,10 @@
import { normalizeAuthUserSnapshot, resolveAuthUserAdminFlag } from '../utils/authUser.js' import {
buildBearerHeaders,
notifyAuthSessionExpired,
readAuthAccessToken
} from '../utils/authSessionStorage.js'
const API_BASE_STORAGE_KEY = 'x-financial-api-base-url' const API_BASE_STORAGE_KEY = 'x-financial-api-base-url'
const AUTH_USER_STORAGE_KEY = 'x-financial-auth-user'
function isHeaderValueSafe(value) { function isHeaderValueSafe(value) {
const normalized = String(value || '').trim() const normalized = String(value || '').trim()
@@ -33,85 +36,6 @@ export function pickSafeHeaderValue(value, fallback = '') {
return '' return ''
} }
function readCurrentUserHeaders() {
if (typeof window === 'undefined') {
return {}
}
const raw = window.sessionStorage.getItem(AUTH_USER_STORAGE_KEY)
if (!raw) {
return {}
}
try {
const payload = JSON.parse(raw)
const user = normalizeAuthUserSnapshot(payload)
const username = user.username
const name = user.name || username
const roleCodes = user.roleCodes
const isAdmin = resolveAuthUserAdminFlag(payload, roleCodes)
const department = user.department || user.departmentName
const costCenter = user.costCenter
const position = user.position
const grade = user.grade
const employeeNo = user.employeeNo
const managerName = user.managerName
const safeUsername = pickSafeHeaderValue(username)
const safeName = pickSafeHeaderValue(name)
const safeDepartment = pickSafeHeaderValue(department)
const safeCostCenter = pickSafeHeaderValue(costCenter)
const safePosition = pickSafeHeaderValue(position)
const safeGrade = pickSafeHeaderValue(grade)
const safeEmployeeNo = pickSafeHeaderValue(employeeNo)
const safeManagerName = pickSafeHeaderValue(managerName)
if (!safeUsername && !safeName) {
return {}
}
const headers = {
'x-auth-role-codes': roleCodes.join(','),
'x-auth-is-admin': String(isAdmin)
}
if (safeUsername) {
headers['x-auth-username'] = safeUsername
}
if (safeName) {
headers['x-auth-name'] = safeName
}
if (safeDepartment) {
headers['x-auth-department'] = safeDepartment
}
if (safeCostCenter) {
headers['x-auth-cost-center'] = safeCostCenter
}
if (safePosition) {
headers['x-auth-position'] = safePosition
}
if (safeGrade) {
headers['x-auth-grade'] = safeGrade
}
if (safeEmployeeNo) {
headers['x-auth-employee-no'] = safeEmployeeNo
}
if (safeManagerName) {
headers['x-auth-manager-name'] = safeManagerName
}
return headers
} catch {
return {}
}
}
function normalizeApiBaseUrl(value) { function normalizeApiBaseUrl(value) {
return String(value || '/api/v1').replace(/\/$/, '') return String(value || '/api/v1').replace(/\/$/, '')
} }
@@ -260,8 +184,20 @@ function sanitizeHeaders(headers) {
return nextHeaders return nextHeaders
} }
function buildApiResponseError(response, payload, { auth, handleUnauthorized }) {
const error = new Error(resolveErrorMessage(payload))
error.status = response.status
if (response.status === 401 && auth && handleUnauthorized && readAuthAccessToken()) {
error.code = 'AUTH_SESSION_EXPIRED'
notifyAuthSessionExpired()
}
return error
}
export async function apiRequest(path, options = {}) { export async function apiRequest(path, options = {}) {
const { const {
auth = true,
handleUnauthorized = true,
contentType = 'application/json', contentType = 'application/json',
responseType = 'json', responseType = 'json',
headers: customHeaders, headers: customHeaders,
@@ -270,10 +206,15 @@ export async function apiRequest(path, options = {}) {
...fetchOptions ...fetchOptions
} = options } = options
const headers = sanitizeHeaders({ const headers = sanitizeHeaders(customHeaders || {})
...readCurrentUserHeaders(), if (auth) {
...(customHeaders || {}) Object.keys(headers).forEach((key) => {
}) if (key.toLowerCase() === 'authorization') {
delete headers[key]
}
})
Object.assign(headers, buildBearerHeaders())
}
if (contentType !== null && typeof headers['Content-Type'] === 'undefined') { if (contentType !== null && typeof headers['Content-Type'] === 'undefined') {
headers['Content-Type'] = contentType headers['Content-Type'] = contentType
@@ -327,7 +268,7 @@ export async function apiRequest(path, options = {}) {
payload = null payload = null
} }
throw new Error(resolveErrorMessage(payload)) throw buildApiResponseError(response, payload, { auth, handleUnauthorized })
} }
return response.blob() return response.blob()
@@ -341,7 +282,7 @@ export async function apiRequest(path, options = {}) {
} }
if (!response.ok) { if (!response.ok) {
throw new Error(resolveErrorMessage(payload)) throw buildApiResponseError(response, payload, { auth, handleUnauthorized })
} }
return payload return payload

View File

@@ -1,7 +1,10 @@
import { apiRequest, getRuntimeApiBaseUrl } from './api.js' import { apiRequest, getRuntimeApiBaseUrl } from './api.js'
import { buildBearerHeaders } from '../utils/authSessionStorage.js'
export function login(payload) { export function login(payload) {
return apiRequest('/auth/login', { return apiRequest('/auth/login', {
auth: false,
handleUnauthorized: false,
method: 'POST', method: 'POST',
body: JSON.stringify(payload) body: JSON.stringify(payload)
}) })
@@ -11,6 +14,14 @@ export function fetchCurrentAuthUser() {
return apiRequest('/auth/me') return apiRequest('/auth/me')
} }
export function revokeCurrentAuthSession(payload) {
return apiRequest('/auth/logout', {
method: 'POST',
handleUnauthorized: false,
body: JSON.stringify(payload || {})
})
}
export function finishSession(sessionId, payload) { export function finishSession(sessionId, payload) {
return apiRequest(`/auth/sessions/${encodeURIComponent(sessionId)}/finish`, { return apiRequest(`/auth/sessions/${encodeURIComponent(sessionId)}/finish`, {
method: 'POST', method: 'POST',
@@ -29,13 +40,12 @@ export function finishSessionOnUnload(sessionId, payload) {
const url = `${getRuntimeApiBaseUrl()}/auth/sessions/${encodeURIComponent(normalizedSessionId)}/finish` const url = `${getRuntimeApiBaseUrl()}/auth/sessions/${encodeURIComponent(normalizedSessionId)}/finish`
const body = JSON.stringify(payload || {}) const body = JSON.stringify(payload || {})
if (typeof window.navigator?.sendBeacon === 'function') {
return window.navigator.sendBeacon(url, new Blob([body], { type: 'application/json' }))
}
window.fetch(url, { window.fetch(url, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: {
'Content-Type': 'application/json',
...buildBearerHeaders()
},
body, body,
keepalive: true keepalive: true
}).catch(() => {}) }).catch(() => {})

View File

@@ -1,4 +1,8 @@
import { apiRequest, getRuntimeApiBaseUrl } from './api.js' import { apiRequest, getRuntimeApiBaseUrl } from './api.js'
import {
buildBearerHeaders,
notifyAuthSessionExpired
} from '../utils/authSessionStorage.js'
export function fetchStewardPlan(payload, options = {}) { export function fetchStewardPlan(payload, options = {}) {
return apiRequest('/steward/plans', { return apiRequest('/steward/plans', {
@@ -63,7 +67,8 @@ export async function fetchStewardPlanStream(payload, handlers = {}, options = {
response = await fetch(`${getRuntimeApiBaseUrl()}/steward/plans/stream`, { response = await fetch(`${getRuntimeApiBaseUrl()}/steward/plans/stream`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json',
...buildBearerHeaders()
}, },
body: JSON.stringify(payload), body: JSON.stringify(payload),
signal: controller?.signal signal: controller?.signal
@@ -78,6 +83,9 @@ export async function fetchStewardPlanStream(payload, handlers = {}, options = {
if (!response.ok) { if (!response.ok) {
clearAbortTimer() clearAbortTimer()
if (response.status === 401) {
notifyAuthSessionExpired()
}
throw new Error(await resolveStreamError(response)) throw new Error(await resolveStreamError(response))
} }

View File

@@ -242,6 +242,10 @@ export function canAccessAppView(user, viewId) {
return VIEW_ROLE_RULES.budget.some((roleCode) => roleCodes.includes(roleCode)) return VIEW_ROLE_RULES.budget.some((roleCode) => roleCodes.includes(roleCode))
} }
if (viewId === 'settings') {
return isPlatformAdminUser(user)
}
if (isManagerUser(user)) { if (isManagerUser(user)) {
return true return true
} }

View File

@@ -1,6 +1,7 @@
import { import {
finishSession, finishSession,
finishSessionOnUnload finishSessionOnUnload,
revokeCurrentAuthSession
} from '../services/auth.js' } from '../services/auth.js'
const AUTH_SESSION_ID_KEY = 'x-financial-auth-session-id' const AUTH_SESSION_ID_KEY = 'x-financial-auth-session-id'
@@ -88,3 +89,11 @@ export function finalizeAuthSession(reason, options = {}) {
console.warn('Failed to finish auth session:', error) console.warn('Failed to finish auth session:', error)
}) })
} }
export function finalizeAndRevokeAuthSession(reason) {
const sessionId = readStoredSessionId()
return revokeCurrentAuthSession({
...buildSessionFinishPayload(reason),
sessionId
})
}

View File

@@ -0,0 +1,71 @@
export const AUTH_ACCESS_TOKEN_KEY = 'x-financial-auth-access-token'
export const AUTH_EXPIRES_AT_KEY = 'x-financial-auth-expires-at'
export const AUTH_SESSION_EXPIRED_EVENT = 'x-financial:auth-expired'
function canUseSessionStorage() {
return typeof window !== 'undefined' && typeof window.sessionStorage !== 'undefined'
}
export function readAuthAccessToken() {
if (!canUseSessionStorage()) {
return ''
}
return String(window.sessionStorage.getItem(AUTH_ACCESS_TOKEN_KEY) || '').trim()
}
export function readAuthExpiresAt() {
if (!canUseSessionStorage()) {
return 0
}
const raw = String(window.sessionStorage.getItem(AUTH_EXPIRES_AT_KEY) || '').trim()
if (!raw) {
return 0
}
const timestamp = Date.parse(raw)
return Number.isFinite(timestamp) ? timestamp : 0
}
export function hasValidAuthSession(now = Date.now()) {
const accessToken = readAuthAccessToken()
const expiresAt = readAuthExpiresAt()
return Boolean(accessToken && expiresAt && expiresAt > now)
}
export function persistAuthCredentials(accessToken, expiresAt) {
if (!canUseSessionStorage()) {
return
}
const normalizedToken = String(accessToken || '').trim()
const normalizedExpiresAt = String(expiresAt || '').trim()
if (!normalizedToken || !normalizedExpiresAt || !Number.isFinite(Date.parse(normalizedExpiresAt))) {
throw new Error('登录接口未返回有效的认证凭证。')
}
window.sessionStorage.setItem(AUTH_ACCESS_TOKEN_KEY, normalizedToken)
window.sessionStorage.setItem(AUTH_EXPIRES_AT_KEY, normalizedExpiresAt)
}
export function clearAuthCredentials() {
if (!canUseSessionStorage()) {
return
}
window.sessionStorage.removeItem(AUTH_ACCESS_TOKEN_KEY)
window.sessionStorage.removeItem(AUTH_EXPIRES_AT_KEY)
}
export function buildBearerHeaders() {
const accessToken = readAuthAccessToken()
if (!accessToken) {
return {}
}
return { Authorization: `Bearer ${accessToken}` }
}
export function notifyAuthSessionExpired() {
if (typeof window === 'undefined' || typeof window.dispatchEvent !== 'function') {
return
}
const event = typeof CustomEvent === 'function'
? new CustomEvent(AUTH_SESSION_EXPIRED_EVENT)
: { type: AUTH_SESSION_EXPIRED_EVENT }
window.dispatchEvent(event)
}

View File

@@ -108,6 +108,11 @@ test('budget center is visible to platform admin, budget monitor, and executive
assert.equal(canAccessAppView({ roleCodes: ['manager'] }, 'budget'), false) assert.equal(canAccessAppView({ roleCodes: ['manager'] }, 'budget'), false)
}) })
test('system settings are visible to platform admin instead of business managers', () => {
assert.equal(canAccessAppView({ isAdmin: true, roleCodes: ['manager'] }, 'settings'), true)
assert.equal(canAccessAppView({ roleCodes: ['manager'] }, 'settings'), false)
})
test('budget edit and department switching are limited to admin and senior finance', () => { test('budget edit and department switching are limited to admin and senior finance', () => {
assert.equal(canEditBudgetCenter({ username: 'admin', roleCodes: ['manager'] }), true) assert.equal(canEditBudgetCenter({ username: 'admin', roleCodes: ['manager'] }), true)
assert.equal(canSwitchBudgetDepartments({ username: 'admin', roleCodes: ['manager'] }), true) assert.equal(canSwitchBudgetDepartments({ username: 'admin', roleCodes: ['manager'] }), true)

View File

@@ -45,8 +45,10 @@ async function testSupportsBlobResponses() {
assert.equal(payload, blob) assert.equal(payload, blob)
} }
async function testInjectsAuthenticatedUserHeaders() { async function testInjectsBearerTokenWithoutUserControlledIdentityHeaders() {
const sessionStorage = new Map([ const sessionStorage = new Map([
['x-financial-auth-access-token', 'opaque-access-token'],
['x-financial-auth-expires-at', '2099-01-01T00:00:00.000Z'],
[ [
'x-financial-auth-user', 'x-financial-auth-user',
JSON.stringify({ JSON.stringify({
@@ -84,27 +86,16 @@ async function testInjectsAuthenticatedUserHeaders() {
await apiRequest('/knowledge/library') await apiRequest('/knowledge/library')
assert.equal(capturedOptions.headers['x-auth-username'], 'admin') assert.equal(capturedOptions.headers.Authorization, 'Bearer opaque-access-token')
assert.equal(capturedOptions.headers['x-auth-name'], 'Admin User') assert.equal(capturedOptions.headers['x-auth-username'], undefined)
assert.equal(capturedOptions.headers['x-auth-position'], 'System Manager') assert.equal(capturedOptions.headers['x-auth-role-codes'], undefined)
assert.equal(capturedOptions.headers['x-auth-grade'], 'M5') assert.equal(capturedOptions.headers['x-auth-is-admin'], undefined)
assert.equal(capturedOptions.headers['x-auth-employee-no'], 'E-001')
assert.equal(capturedOptions.headers['x-auth-manager-name'], 'Approver User')
assert.equal(capturedOptions.headers['x-auth-role-codes'], 'manager')
assert.equal(capturedOptions.headers['x-auth-is-admin'], 'true')
} }
async function testInjectsLegacyAdminHeaderFromSnakeCaseFlag() { async function testLoginCanDisableBearerInjection() {
const sessionStorage = new Map([ const sessionStorage = new Map([
[ ['x-financial-auth-access-token', 'stale-token'],
'x-financial-auth-user', ['x-financial-auth-expires-at', '2099-01-01T00:00:00.000Z']
JSON.stringify({
username: 'superadmin',
name: 'superadmin',
roleCodes: ['manager'],
is_admin: true
})
]
]) ])
global.window = { global.window = {
@@ -127,11 +118,74 @@ async function testInjectsLegacyAdminHeaderFromSnakeCaseFlag() {
} }
} }
await apiRequest('/reimbursements/claims/demo', { method: 'DELETE' }) await apiRequest('/auth/login', {
auth: false,
handleUnauthorized: false,
method: 'POST',
body: '{}'
})
assert.equal(capturedOptions.headers['x-auth-username'], 'superadmin') assert.equal(capturedOptions.headers.Authorization, undefined)
assert.equal(capturedOptions.headers['x-auth-role-codes'], 'manager') }
assert.equal(capturedOptions.headers['x-auth-is-admin'], 'true')
async function testRejectsCustomAuthorizationOverride() {
const sessionStorage = new Map([
['x-financial-auth-access-token', 'server-issued-token'],
['x-financial-auth-expires-at', '2099-01-01T00:00:00.000Z']
])
global.window = {
sessionStorage: {
getItem(key) {
return sessionStorage.get(key) ?? null
}
}
}
let capturedOptions = null
global.fetch = async (_url, options) => {
capturedOptions = options
return { ok: true, async json() { return { ok: true } } }
}
await apiRequest('/knowledge/library', {
headers: { Authorization: 'Bearer attacker-token' }
})
assert.equal(capturedOptions.headers.Authorization, 'Bearer server-issued-token')
}
async function testUnauthorizedResponsePublishesSessionExpiredEvent() {
const sessionStorage = new Map([
['x-financial-auth-access-token', 'expired-token'],
['x-financial-auth-expires-at', '2099-01-01T00:00:00.000Z']
])
const events = []
global.window = {
sessionStorage: {
getItem(key) {
return sessionStorage.get(key) ?? null
}
},
dispatchEvent(event) {
events.push(event.type)
}
}
global.fetch = async () => ({
ok: false,
status: 401,
async json() {
return { detail: '登录会话已失效。' }
}
})
await assert.rejects(
() => apiRequest('/auth/me'),
(error) => {
assert.equal(error.status, 401)
assert.equal(error.code, 'AUTH_SESSION_EXPIRED')
return true
}
)
assert.deepEqual(events, ['x-financial:auth-expired'])
} }
async function testFormatsValidationErrors() { async function testFormatsValidationErrors() {
@@ -192,8 +246,10 @@ async function testRejectsWithCustomTimeoutMessage() {
async function run() { async function run() {
await testUsesCustomContentTypeHeader() await testUsesCustomContentTypeHeader()
await testSupportsBlobResponses() await testSupportsBlobResponses()
await testInjectsAuthenticatedUserHeaders() await testInjectsBearerTokenWithoutUserControlledIdentityHeaders()
await testInjectsLegacyAdminHeaderFromSnakeCaseFlag() await testLoginCanDisableBearerInjection()
await testRejectsCustomAuthorizationOverride()
await testUnauthorizedResponsePublishesSessionExpiredEvent()
await testFormatsValidationErrors() await testFormatsValidationErrors()
await testRejectsWithCustomTimeoutMessage() await testRejectsWithCustomTimeoutMessage()
console.log('api-request tests passed') console.log('api-request tests passed')

View File

@@ -0,0 +1,51 @@
import assert from 'node:assert/strict'
import {
AUTH_ACCESS_TOKEN_KEY,
AUTH_EXPIRES_AT_KEY,
buildBearerHeaders,
clearAuthCredentials,
hasValidAuthSession,
persistAuthCredentials,
readAuthAccessToken,
readAuthExpiresAt
} from '../src/utils/authSessionStorage.js'
const storage = new Map()
global.window = {
sessionStorage: {
getItem(key) {
return storage.get(key) ?? null
},
setItem(key, value) {
storage.set(key, String(value))
},
removeItem(key) {
storage.delete(key)
}
}
}
const expiresAt = '2099-01-01T00:00:00.000Z'
persistAuthCredentials('opaque-token', expiresAt)
assert.equal(readAuthAccessToken(), 'opaque-token')
assert.equal(readAuthExpiresAt(), Date.parse(expiresAt))
assert.equal(hasValidAuthSession(Date.parse('2098-01-01T00:00:00.000Z')), true)
assert.deepEqual(buildBearerHeaders(), { Authorization: 'Bearer opaque-token' })
storage.set(AUTH_EXPIRES_AT_KEY, '2020-01-01T00:00:00.000Z')
assert.equal(hasValidAuthSession(Date.parse('2021-01-01T00:00:00.000Z')), false)
storage.set(AUTH_ACCESS_TOKEN_KEY, '')
assert.deepEqual(buildBearerHeaders(), {})
assert.throws(
() => persistAuthCredentials('', expiresAt),
/未返回有效的认证凭证/
)
clearAuthCredentials()
assert.equal(storage.has(AUTH_ACCESS_TOKEN_KEY), false)
assert.equal(storage.has(AUTH_EXPIRES_AT_KEY), false)
console.log('auth session storage tests passed')

View File

@@ -0,0 +1,35 @@
import assert from 'node:assert/strict'
import { isSetupCompletedState, normalizeState } from '../vite.config.js'
const completedEnv = {
SETUP_COMPLETED: 'true',
POSTGRES_HOST: 'database.internal',
POSTGRES_PORT: '5432',
POSTGRES_DB: 'x_financial',
POSTGRES_USER: 'postgres-admin',
POSTGRES_PASSWORD: 'secret',
REDIS_URL: 'redis://redis.internal:6379/0'
}
assert.equal(isSetupCompletedState(completedEnv, true), true)
assert.equal(isSetupCompletedState(completedEnv, false), false)
assert.equal(isSetupCompletedState({ SETUP_COMPLETED: 'false' }, true), false)
const publicState = normalizeState(completedEnv, { adminConfigured: true })
assert.equal(publicState.initialized, true)
assert.equal(publicState.database.host, '')
assert.equal(publicState.database.username, '')
assert.equal(publicState.redis.url, '')
assert.equal(publicState.database.password_configured, true)
const setupState = normalizeState(
{ ...completedEnv, SETUP_COMPLETED: 'false' },
{ adminConfigured: false }
)
assert.equal(setupState.initialized, false)
assert.equal(setupState.database.host, 'database.internal')
assert.equal(setupState.database.username, 'postgres-admin')
assert.equal(setupState.redis.url, 'redis://redis.internal:6379/0')
console.log('vite setup lock tests passed')

View File

@@ -19,6 +19,7 @@ const adminScryptOptions = { N: 16384, r: 8, p: 1 }
const adminScryptKeyLength = 64 const adminScryptKeyLength = 64
let backendStartPromise = null let backendStartPromise = null
let backendStartState = createBackendStartState() let backendStartState = createBackendStartState()
let backendStartAuthorized = false
function createBackendStartState() { function createBackendStartState() {
return { return {
@@ -400,11 +401,21 @@ function buildClientEnvUpdates(payload, apiBaseUrl) {
} }
} }
function normalizeState(env) { export function isSetupCompletedState(env, adminConfigured) {
const adminConfigured = Boolean(readAdminSecret()) return String(env.SETUP_COMPLETED || '').toLowerCase() === 'true' && adminConfigured
}
function isSetupCompleted() {
return isSetupCompletedState(readEnvState(), Boolean(readAdminSecret()))
}
export function normalizeState(env, options = {}) {
const adminConfigured = options.adminConfigured ?? Boolean(readAdminSecret())
const initialized = isSetupCompletedState(env, adminConfigured)
const redactInfrastructure = options.redactInfrastructure ?? initialized
return { return {
initialized: String(env.SETUP_COMPLETED || '').toLowerCase() === 'true' && adminConfigured, initialized,
company: { company: {
name: env.COMPANY_NAME || '', name: env.COMPANY_NAME || '',
code: env.COMPANY_CODE || '', code: env.COMPANY_CODE || '',
@@ -423,19 +434,27 @@ function normalizeState(env) {
}, },
database: { database: {
driver: 'postgresql', driver: 'postgresql',
host: env.POSTGRES_HOST || '127.0.0.1', host: redactInfrastructure ? '' : env.POSTGRES_HOST || '127.0.0.1',
port: Number(env.POSTGRES_PORT || 5432), port: Number(env.POSTGRES_PORT || 5432),
name: env.POSTGRES_DB || 'x_financial', name: env.POSTGRES_DB || 'x_financial',
username: env.POSTGRES_USER || 'postgres', username: redactInfrastructure ? '' : env.POSTGRES_USER || 'postgres',
password_configured: Boolean(env.POSTGRES_PASSWORD) password_configured: Boolean(env.POSTGRES_PASSWORD)
}, },
redis: { redis: {
enabled: Boolean(env.REDIS_URL), enabled: Boolean(env.REDIS_URL),
url: env.REDIS_URL || '' url: redactInfrastructure ? '' : env.REDIS_URL || ''
} }
} }
} }
function rejectCompletedSetup(res) {
if (!isSetupCompleted()) {
return false
}
sendJson(res, 403, { detail: '系统已完成初始化,本地初始化桥已锁定。' })
return true
}
async function readJsonBody(req) { async function readJsonBody(req) {
const chunks = [] const chunks = []
@@ -829,6 +848,9 @@ function localSetupPlugin() {
server.middlewares.use('/__setup/auth/login', async (req, res) => { server.middlewares.use('/__setup/auth/login', async (req, res) => {
try { try {
if (rejectCompletedSetup(res)) {
return
}
if (req.method !== 'POST') { if (req.method !== 'POST') {
sendJson(res, 405, { detail: 'Method not allowed' }) sendJson(res, 405, { detail: 'Method not allowed' })
return return
@@ -866,6 +888,9 @@ function localSetupPlugin() {
server.middlewares.use('/__setup/bootstrap/runtime', async (req, res) => { server.middlewares.use('/__setup/bootstrap/runtime', async (req, res) => {
try { try {
if (rejectCompletedSetup(res)) {
return
}
if (req.method !== 'PUT') { if (req.method !== 'PUT') {
sendJson(res, 405, { detail: 'Method not allowed' }) sendJson(res, 405, { detail: 'Method not allowed' })
return return
@@ -897,6 +922,9 @@ function localSetupPlugin() {
server.middlewares.use('/__setup/bootstrap/database', async (req, res) => { server.middlewares.use('/__setup/bootstrap/database', async (req, res) => {
try { try {
if (rejectCompletedSetup(res)) {
return
}
if (req.method !== 'PUT') { if (req.method !== 'PUT') {
sendJson(res, 405, { detail: 'Method not allowed' }) sendJson(res, 405, { detail: 'Method not allowed' })
return return
@@ -930,7 +958,7 @@ function localSetupPlugin() {
try { try {
if (req.method === 'GET') { if (req.method === 'GET') {
const logFile = path.join(rootDir, 'server', 'logs', 'bootstrap-backend.log') const logFile = path.join(rootDir, 'server', 'logs', 'bootstrap-backend.log')
backendStartState.logTail = readBackendLogTail(logFile) backendStartState.logTail = isSetupCompleted() ? '' : readBackendLogTail(logFile)
sendJson(res, 200, cloneBackendStartState()) sendJson(res, 200, cloneBackendStartState())
return return
} }
@@ -940,8 +968,16 @@ function localSetupPlugin() {
return return
} }
if (isSetupCompleted() && !backendStartAuthorized) {
sendJson(res, 403, { detail: '系统已完成初始化,后端启动桥已锁定。' })
return
}
try { try {
const result = await startBackendAndWait() const result = await startBackendAndWait()
if (result.completed) {
backendStartAuthorized = false
}
sendJson(res, 200, result) sendJson(res, 200, result)
} catch (error) { } catch (error) {
sendJson(res, 500, { sendJson(res, 500, {
@@ -968,6 +1004,10 @@ function localSetupPlugin() {
return return
} }
if (rejectCompletedSetup(res)) {
return
}
const currentEnv = readEnvState() const currentEnv = readEnvState()
const payload = resolveRuntimePayload(await readJsonBody(req), currentEnv) const payload = resolveRuntimePayload(await readJsonBody(req), currentEnv)
const validationError = validateSetupPayload(payload) const validationError = validateSetupPayload(payload)
@@ -1012,6 +1052,7 @@ function localSetupPlugin() {
...buildClientEnvUpdates(payload, apiBaseUrl) ...buildClientEnvUpdates(payload, apiBaseUrl)
}) })
backendStartAuthorized = true
sendJson(res, 201, normalizeState(readEnvState())) sendJson(res, 201, normalizeState(readEnvState()))
} catch (error) { } catch (error) {
sendJson(res, 500, { sendJson(res, 500, {