diff --git a/document/development/2026-07-18/dev-logs/bugs/admin-tenant-context-management-modules.md b/document/development/2026-07-18/dev-logs/bugs/admin-tenant-context-management-modules.md new file mode 100644 index 0000000..61fad04 --- /dev/null +++ b/document/development/2026-07-18/dev-logs/bugs/admin-tenant-context-management-modules.md @@ -0,0 +1,13 @@ +# 管理员会话未绑定所选企业导致管理模块不可用 + +日期:2026-07-18 +文档路径:document/development/2026-07-18/dev-logs/bugs/admin-tenant-context-management-modules.md + +## 修复记录 +- 15:00:记录 bug 修复:`admin/admin` 登录后仍落在 `platform` 工作域,数字员工和员工管理无法读取 `default` 企业数据。 + - Git 提交检查:`git fetch --all --prune` 成功;upstream `origin/main`;upstream 新提交:未发现;本地 ahead 19 条,最新为 `07241b46 fix(docker): manage local postgres in default compose`、`787bc3a4 feat(platform): close AI expense value loop`、`242d68c3 feat(approval): add task workflow and waiver decisions`、`28b834ed fix(approval): replay immutable action responses`、`4940ebc4 feat(approval): add safe risk disposition workflow`、`ee88a36b feat(ai): add tenant-safe hierarchical expense learning`、`6bdf65bc feat(expenses): add authoritative pre-review workflow`、`ae3f02c3 feat(expense): add persistent zero-entry receipt association`,另有 11 条。 + - 原因:管理员认证成功后固定构造为 `platform` 租户,忽略登录请求中的企业代码;会话恢复也再次回落到平台域,导致企业级资产与员工接口拿不到有效企业上下文。 + - 修改:`AuthService` 在管理员登录和会话恢复时解析请求企业 ID/企业代码,只接受启用企业,并保持 `is_admin=true`;未选择企业时仍保留平台管理员语义。 + - 修改:新增管理员 `default` 企业登录、会话恢复、停用/未知企业拒绝测试,以及员工 meta/list 端点的真实管理员租户回归,防止平台域误触发员工初始化。 + - 验证:容器内后端联合回归 `65 passed`,Ruff 全部通过;使用 `admin/admin` 和 `default` 实际登录后,数字员工技能页显示 9 条技能,员工管理显示 105 人、100 人在职、3 人试用、2 人停用,曹笑竹详情及角色信息可正常打开。 + - 影响:管理员现在能在所选企业内稳定使用数字员工、员工与组织、企业规则等租户级功能,同时不会扩大普通用户或跨企业访问权限。 diff --git a/document/development/2026-07-18/dev-logs/bugs/platform-employee-directory-tenant-guard.md b/document/development/2026-07-18/dev-logs/bugs/platform-employee-directory-tenant-guard.md new file mode 100644 index 0000000..e2b4578 --- /dev/null +++ b/document/development/2026-07-18/dev-logs/bugs/platform-employee-directory-tenant-guard.md @@ -0,0 +1,14 @@ +# 平台工作域误初始化企业员工目录 + +日期:2026-07-18 +文档路径:document/development/2026-07-18/dev-logs/bugs/platform-employee-directory-tenant-guard.md + +## 修复记录 + +- 15:54:记录 bug 修复:平台管理员未选择企业时访问员工接口,会在 `platform` 工作域初始化并返回企业员工数据。 + - Git 提交检查:`git fetch --all --prune` 成功;upstream `origin/main` 无新提交;本地 ahead 19 条,最新为 `07241b46 fix(docker): manage local postgres in default compose`、`787bc3a4 feat(platform): close AI expense value loop`、`242d68c3 feat(approval): add task workflow and waiver decisions`,另有 16 条。 + - 原因:员工路由只校验管理员身份,没有区分平台工作域和企业工作域;首次访问还会触发演示员工初始化,造成平台租户数据污染。 + - 修改:新增企业管理员依赖并应用到员工管理整组接口;平台工作域统一返回 403,前端同步隐藏企业员工目录入口;新增接口拒绝、无数据副作用和导航权限回归测试。 + - 操作:在 `local-x-financial-linux` 容器内完成先失败后通过的接口测试、后端联合回归、前端全量回归、Ruff 和生产构建。 + - 验证:平台管理员访问员工 meta/list 均返回 403 且不生成平台员工或组织;`admin/admin + default` 实际访问员工管理正常显示 105 人;后端联合回归 `165 passed`,前端全量回归 `821/821` 通过。 + - 影响:员工与组织数据只能在明确企业上下文中读取和初始化,避免平台域污染及跨工作域误操作。 diff --git a/server/src/app/api/deps.py b/server/src/app/api/deps.py index 43344a8..0ffaccf 100644 --- a/server/src/app/api/deps.py +++ b/server/src/app/api/deps.py @@ -8,6 +8,7 @@ from sqlalchemy.orm import Session from app.db.session import get_session_factory from app.services.auth import AuthService from app.services.auth_sessions import AuthSessionService +from app.services.tenant_registry import PLATFORM_TENANT_ID def get_db() -> Generator[Session, None, None]: @@ -129,6 +130,19 @@ def require_admin_user( ) +def require_enterprise_admin_user( + current_user: Annotated[CurrentUserContext, Depends(require_admin_user)], +) -> CurrentUserContext: + """限制企业目录类管理接口,避免在平台工作域误生成业务数据。""" + + if current_user.tenant_id == PLATFORM_TENANT_ID: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="员工目录只能在企业工作域内使用。", + ) + return current_user + + def require_platform_admin_user( current_user: Annotated[CurrentUserContext, Depends(get_current_user)], ) -> CurrentUserContext: diff --git a/server/src/app/api/v1/endpoints/employees.py b/server/src/app/api/v1/endpoints/employees.py index a3a9472..4e71813 100644 --- a/server/src/app/api/v1/endpoints/employees.py +++ b/server/src/app/api/v1/endpoints/employees.py @@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, from fastapi.responses import Response from sqlalchemy.orm import Session -from app.api.deps import CurrentUserContext, get_db, require_admin_user +from app.api.deps import CurrentUserContext, get_db, require_enterprise_admin_user from app.api.pagination import PageNumber, PageSize, page_payload, wants_page from app.schemas.common import ErrorResponse, PaginatedResponse from app.schemas.employee import ( @@ -19,9 +19,9 @@ from app.schemas.employee import ( from app.services.employee import EmployeeService from app.services.employee_pagination import EmployeePaginationService -router = APIRouter(dependencies=[Depends(require_admin_user)]) +router = APIRouter(dependencies=[Depends(require_enterprise_admin_user)]) DbSession = Annotated[Session, Depends(get_db)] -AdminUser = Annotated[CurrentUserContext, Depends(require_admin_user)] +AdminUser = Annotated[CurrentUserContext, Depends(require_enterprise_admin_user)] def _employee_service(db: Session, current_user: CurrentUserContext) -> EmployeeService: diff --git a/server/src/app/services/auth.py b/server/src/app/services/auth.py index 36ebe9c..cfd9736 100644 --- a/server/src/app/services/auth.py +++ b/server/src/app/services/auth.py @@ -72,7 +72,11 @@ class AuthService: identifier = payload.username.strip() password = payload.password - admin_user = self._authenticate_admin(identifier, password) + admin_user = self._authenticate_admin( + identifier, + password, + requested_tenant=payload.tenant_id, + ) if admin_user is not None: logger.info("Admin login succeeded identifier=%s", identifier) return self._build_login_response(admin_user) @@ -127,7 +131,14 @@ class AuthService: } if auth_session.username.strip().casefold() not in allowed_identifiers: return None - return self._restore_session_scope(self._build_admin_user(record), auth_session) + try: + tenant_id = self._resolve_admin_tenant_id(auth_session.tenant_id) + except ValueError: + return None + return self._restore_session_scope( + self._build_admin_user(record, tenant_id=tenant_id), + auth_session, + ) if auth_session.principal_type != "employee": return None @@ -189,15 +200,28 @@ class AuthService: return self._serialize_user(self._build_employee_user(employee)) - def _authenticate_admin(self, identifier: str, password: str) -> AuthenticatedUser | None: + def _authenticate_admin( + self, + identifier: str, + password: str, + *, + requested_tenant: str | None, + ) -> AuthenticatedUser | None: record = SettingsService(self.db).verify_admin_login(identifier, password) if record is None: return None - return self._build_admin_user(record) + return self._build_admin_user( + record, + tenant_id=self._resolve_admin_tenant_id(requested_tenant), + ) @staticmethod - def _build_admin_user(record: Any) -> AuthenticatedUser: + def _build_admin_user( + record: Any, + *, + tenant_id: str = PLATFORM_TENANT_ID, + ) -> AuthenticatedUser: admin_username = record.account.strip() admin_email = record.email.strip() display_name = admin_username or admin_email or "系统管理员" @@ -218,10 +242,30 @@ class AuthService: role_codes=["manager"], email=admin_email or f"{admin_username}@local", avatar=display_name[:1].upper(), - tenant_id=PLATFORM_TENANT_ID, + tenant_id=required_tenant_id(tenant_id), is_admin=True, ) + def _resolve_admin_tenant_id(self, requested_tenant: str | None) -> str: + """把管理员当前工作域绑定到登录时选择的有效企业。""" + + normalized_tenant = str(requested_tenant or "").strip() + if not normalized_tenant or normalized_tenant == PLATFORM_TENANT_ID: + return PLATFORM_TENANT_ID + + tenant = self.db.scalar( + select(Tenant).where( + or_( + Tenant.tenant_id == normalized_tenant, + Tenant.tenant_code == normalized_tenant, + ), + Tenant.status == "active", + ) + ) + if tenant is None: + raise ValueError("企业代码不存在或当前不可用。") + return required_tenant_id(tenant.tenant_id) + def _authenticate_employee( self, identifier: str, diff --git a/server/tests/test_auth_service.py b/server/tests/test_auth_service.py index 27a83c3..6ef5a68 100644 --- a/server/tests/test_auth_service.py +++ b/server/tests/test_auth_service.py @@ -7,6 +7,7 @@ from sqlalchemy.pool import StaticPool from app.db.base import Base from app.models.auth_session import AuthSession +from app.models.tenant import Tenant from app.models.user_session_metric import UserSessionMetric from app.schemas.auth import LoginRequest from app.schemas.settings import SettingsWrite @@ -16,7 +17,7 @@ from app.services.employee import EmployeeService from app.services.settings import SettingsService -def build_session() -> Session: +def build_session() -> Session: engine = create_engine( "sqlite+pysqlite:///:memory:", connect_args={"check_same_thread": False}, @@ -24,7 +25,16 @@ def build_session() -> Session: ) Base.metadata.create_all(bind=engine) session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False) - return session_factory() + return session_factory() + + +def configure_admin(db: Session, *, account: str = "admin", password: str = "admin") -> None: + settings_service = SettingsService(db) + payload = settings_service.get_settings_snapshot().model_dump() + payload["adminForm"]["adminAccount"] = account + payload["adminForm"]["newPassword"] = password + payload["adminForm"]["confirmPassword"] = password + settings_service.save_settings_snapshot(SettingsWrite(**payload)) def test_employee_can_login_with_seed_default_password() -> None: @@ -65,13 +75,8 @@ def test_current_user_snapshot_refreshes_employee_position() -> None: def test_admin_can_login_with_database_password() -> None: - with build_session() as db: - settings_service = SettingsService(db) - payload = settings_service.get_settings_snapshot().model_dump() - payload["adminForm"]["adminAccount"] = "superadmin" - payload["adminForm"]["newPassword"] = "admin123" - payload["adminForm"]["confirmPassword"] = "admin123" - settings_service.save_settings_snapshot(SettingsWrite(**payload)) + with build_session() as db: + configure_admin(db, account="superadmin", password="admin123") result = AuthService(db).login( LoginRequest(username="superadmin", password="admin123") @@ -82,6 +87,67 @@ def test_admin_can_login_with_database_password() -> None: assert result.user.isAdmin is True assert result.user.position == "系统管理员" assert result.user.roleCodes == ["manager"] + assert result.user.tenantId == "platform" + + +def test_admin_login_uses_requested_active_tenant_and_restores_session_scope() -> None: + with build_session() as db: + configure_admin(db) + db.add( + Tenant( + tenant_id="default", + tenant_code="default", + name="默认企业", + status="active", + ) + ) + db.commit() + + result = AuthService(db).login( + LoginRequest(username="admin", password="admin", tenantId="default") + ) + auth_session = db.query(AuthSession).one() + restored = AuthService(db).get_session_user(auth_session) + + assert result.user.isAdmin is True + assert result.user.tenantId == "default" + assert auth_session.principal_type == "admin" + assert auth_session.tenant_id == "default" + assert restored is not None + assert restored.is_admin is True + assert restored.tenant_id == "default" + + +def test_admin_login_rejects_unknown_or_inactive_requested_tenant() -> None: + with build_session() as db: + configure_admin(db) + db.add( + Tenant( + tenant_id="tenant-disabled", + tenant_code="company-disabled", + name="停用企业", + status="disabled", + ) + ) + db.commit() + + with pytest.raises(ValueError, match="企业代码不存在或当前不可用"): + AuthService(db).login( + LoginRequest( + username="admin", + password="admin", + tenantId="company-disabled", + ) + ) + + with pytest.raises(ValueError, match="企业代码不存在或当前不可用"): + AuthService(db).login( + LoginRequest( + username="admin", + password="admin", + tenantId="missing-tenant", + ) + ) def test_disabled_employee_cannot_login() -> None: diff --git a/server/tests/test_employee_admin_tenant_endpoints.py b/server/tests/test_employee_admin_tenant_endpoints.py new file mode 100644 index 0000000..758e294 --- /dev/null +++ b/server/tests/test_employee_admin_tenant_endpoints.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +from collections.abc import Generator + +from fastapi.testclient import TestClient +from sqlalchemy import create_engine, func, select +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool + +from app.api.deps import get_db +from app.db.base import Base +from app.main import create_app +from app.models.employee import Employee +from app.models.organization import OrganizationUnit +from app.schemas.settings import SettingsWrite +from app.services.settings import SettingsService +from app.services.tenant_registry import TenantRegistryService + + +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: + TenantRegistryService(db).ensure_builtin() + settings_service = SettingsService(db) + payload = settings_service.get_settings_snapshot().model_dump() + payload["adminForm"]["adminAccount"] = "admin" + payload["adminForm"]["newPassword"] = "admin" + payload["adminForm"]["confirmPassword"] = "admin" + 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 test_admin_selected_default_tenant_reads_default_employee_directory() -> None: + client, session_factory = build_client() + + login_response = client.post( + "/api/v1/auth/login", + json={"username": "admin", "password": "admin", "tenantId": "default"}, + ) + + assert login_response.status_code == 200 + login_payload = login_response.json() + assert login_payload["user"]["tenantId"] == "default" + headers = {"Authorization": f"Bearer {login_payload['accessToken']}"} + + meta_response = client.get("/api/v1/employees/meta", headers=headers) + list_response = client.get("/api/v1/employees", headers=headers) + + assert meta_response.status_code == 200 + assert list_response.status_code == 200 + assert meta_response.json()["totalEmployees"] == 30 + assert len(list_response.json()) == 30 + + with session_factory() as db: + default_employee_count = db.scalar( + select(func.count()) + .select_from(Employee) + .where(Employee.tenant_id == "default") + ) + platform_employee_count = db.scalar( + select(func.count()) + .select_from(Employee) + .where(Employee.tenant_id == "platform") + ) + default_unit_count = db.scalar( + select(func.count()) + .select_from(OrganizationUnit) + .where(OrganizationUnit.tenant_id == "default") + ) + platform_unit_count = db.scalar( + select(func.count()) + .select_from(OrganizationUnit) + .where(OrganizationUnit.tenant_id == "platform") + ) + + assert default_employee_count == 30 + assert platform_employee_count == 0 + assert default_unit_count == 7 + assert platform_unit_count == 0 + + +def test_platform_admin_cannot_seed_platform_employee_directory() -> None: + client, session_factory = build_client() + + login_response = client.post( + "/api/v1/auth/login", + json={"username": "admin", "password": "admin"}, + ) + + assert login_response.status_code == 200 + login_payload = login_response.json() + assert login_payload["user"]["tenantId"] == "platform" + headers = {"Authorization": f"Bearer {login_payload['accessToken']}"} + + meta_response = client.get("/api/v1/employees/meta", headers=headers) + list_response = client.get("/api/v1/employees", headers=headers) + + assert meta_response.status_code == 403 + assert list_response.status_code == 403 + assert meta_response.json()["detail"] == "员工目录只能在企业工作域内使用。" + assert list_response.json()["detail"] == "员工目录只能在企业工作域内使用。" + + with session_factory() as db: + platform_employee_count = db.scalar( + select(func.count()) + .select_from(Employee) + .where(Employee.tenant_id == "platform") + ) + platform_unit_count = db.scalar( + select(func.count()) + .select_from(OrganizationUnit) + .where(OrganizationUnit.tenant_id == "platform") + ) + + assert platform_employee_count == 0 + assert platform_unit_count == 0 diff --git a/web/src/utils/accessControl.js b/web/src/utils/accessControl.js index a55d9d6..05bffbb 100644 --- a/web/src/utils/accessControl.js +++ b/web/src/utils/accessControl.js @@ -63,6 +63,10 @@ function normalizedGrade(user) { return String(user?.grade || user?.employeeGrade || '').trim().toUpperCase() } +function normalizedTenantId(user) { + return String(user?.tenantId || user?.tenant_id || '').trim() +} + function departmentIntersects(request, user) { const requestDepartments = collectIdentityNames( request?.dept, @@ -234,6 +238,10 @@ export function canAccessAppView(user, viewId) { return false } + if (viewId === 'employees' && normalizedTenantId(user) === 'platform') { + return false + } + if (viewId === 'budget') { if (isPlatformAdminUser(user)) { return true diff --git a/web/tests/accessControl.test.mjs b/web/tests/accessControl.test.mjs index 92205fd..a15076a 100644 --- a/web/tests/accessControl.test.mjs +++ b/web/tests/accessControl.test.mjs @@ -98,6 +98,34 @@ test('platform admin users do not enter the personal workbench', () => { ) }) +test('platform workspace hides the enterprise employee directory', () => { + const platformAdmin = { + username: 'admin', + isAdmin: true, + tenantId: 'platform', + roleCodes: ['manager'] + } + const enterpriseAdmin = { + username: 'admin', + isAdmin: true, + tenantId: 'default', + roleCodes: ['manager'] + } + + assert.equal(canAccessAppView(platformAdmin, 'employees'), false) + assert.equal(canAccessAppView(enterpriseAdmin, 'employees'), true) + assert.deepEqual( + filterNavItemsByAccess( + [ + { id: 'employees', label: '员工管理' }, + { id: 'settings', label: '系统设置' } + ], + platformAdmin + ).map((item) => item.id), + ['settings'] + ) +}) + test('budget center is visible to platform admin, budget monitor, and executive roles only', () => { assert.equal(canAccessAppView({ isAdmin: true, roleCodes: ['manager'] }, 'budget'), true) assert.equal(canAccessAppView({ username: 'admin', roleCodes: ['manager'] }, 'budget'), true)