Separate cross-team selection from attribution maintenance and apply the same scope to options, history filters and writes. Add an opt-in tenant permission without granting existing roles. Protect authorization mutation paths against self-grants, validate tenant targets and links, and include menu ancestors atomically. Cover role union, revocation, mobile writes and administration boundaries with regression tests.
181 lines
8.9 KiB
Python
181 lines
8.9 KiB
Python
"""Users cannot self-grant project access through the permission administration APIs."""
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.orm import Session
|
|
from sqlalchemy.pool import StaticPool
|
|
|
|
from app.api.admin import router
|
|
from app.core.auth import get_current_user
|
|
from app.core.database import get_db
|
|
from app.models.base import Base
|
|
from app.models.models import SysMenu, SysPost, SysRole, SysRoleMenu, SysUser, SysUserRole
|
|
|
|
|
|
@pytest.fixture
|
|
def env():
|
|
engine = create_engine('sqlite://', connect_args={'check_same_thread': False}, poolclass=StaticPool)
|
|
Base.metadata.create_all(engine)
|
|
db = Session(engine)
|
|
user = dict(user_id='u', role='admin', tenant_no='t')
|
|
db.add_all([
|
|
SysUser(id='u', tenant_no='t', user_no='member', nick_name='Member'),
|
|
SysUser(id='foreign-user', tenant_no='foreign', user_no='foreign', nick_name='Foreign'),
|
|
SysRole(id='role', tenant_no='t', role_name='Member', data_scope='4'),
|
|
SysRole(id='foreign-role', tenant_no='foreign', role_name='Foreign'),
|
|
SysUserRole(id='ur', tenant_no='t', user_id='u', role_id='role'),
|
|
SysPost(id='post', tenant_no='t', post_name='Post', post_code='POST'),
|
|
SysPost(id='foreign-post', tenant_no='foreign', post_name='Foreign', post_code='FOREIGN'),
|
|
SysMenu(id='root', tenant_no='t', parent_id='0', menu_name='Web'),
|
|
SysMenu(id='project', tenant_no='t', parent_id='root', menu_name='项目', perms='menu:project'),
|
|
SysMenu(id='all', tenant_no='t', parent_id='project', menu_name='全部项目', menu_type='F', perms='project:project:all'),
|
|
SysMenu(id='foreign-menu', tenant_no='foreign', parent_id='0', menu_name='Foreign'),
|
|
])
|
|
db.commit()
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
app.dependency_overrides[get_current_user] = lambda: user
|
|
app.dependency_overrides[get_db] = lambda: db
|
|
with TestClient(app) as client:
|
|
yield db, user, client
|
|
db.close()
|
|
engine.dispose()
|
|
|
|
|
|
def grant(db, code):
|
|
db.add_all([SysMenu(id=code, tenant_no='t', menu_name=code, perms=code),
|
|
SysRoleMenu(id=code, tenant_no='t', role_id='role', menu_id=code)])
|
|
db.commit()
|
|
|
|
|
|
@pytest.mark.parametrize('method,path,body', [
|
|
('PUT', '/role-management/role/menus', {'menuIds': ['all']}),
|
|
('POST', '/role-management', {'roleName': '超级管理员'}),
|
|
('PUT', '/role-management/role', {'roleName': '超级管理员'}),
|
|
('DELETE', '/role-management/role', None),
|
|
('PUT', '/role-management/role/data-scope', {'dataScope': '1'}),
|
|
('POST', '/users', {'userNo': 'attacker', 'nickName': 'Attacker', 'password': 'TestOnly123!', 'roleIds': ['role']}),
|
|
('PUT', '/users/u', {'roleIds': ['foreign-role']}),
|
|
('PUT', '/users/u', {'postIds': ['post']}),
|
|
('POST', '/users/u/reset-password', None),
|
|
('DELETE', '/users/u', None),
|
|
('PUT', '/posts/post/roles', {'roleIds': ['role']}),
|
|
('PUT', '/posts/post/users', {'userIds': ['u']}),
|
|
('PUT', '/posts/post', {'status': '1'}),
|
|
('POST', '/menus', {'menuName': 'Grant', 'perms': 'project:project:all'}),
|
|
('PUT', '/menus/project', {'perms': 'project:project:all'}),
|
|
('DELETE', '/menus/all', None),
|
|
])
|
|
def test_business_admin_cannot_mutate_permission_graph(env, method, path, body):
|
|
_, _, client = env
|
|
result = client.request(method, '/web/admin' + path, json=body)
|
|
assert result.status_code == 403
|
|
|
|
|
|
def test_role_manager_grants_leaf_and_ancestors_only(env):
|
|
db, _, client = env
|
|
grant(db, 'system:role:list')
|
|
result = client.put('/web/admin/role-management/role/menus', json={'menuIds': ['all', 'system:role:list']})
|
|
assert result.json()['code'] == 200
|
|
rows = db.query(SysRoleMenu).filter_by(role_id='role', tenant_no='t', del_flag=0).all()
|
|
assert {r.menu_id for r in rows} == {'all', 'project', 'root', 'system:role:list'}
|
|
# Same save is idempotent and does not create duplicate active grants.
|
|
assert client.put('/web/admin/role-management/role/menus', json={'menuIds': ['all', 'system:role:list']}).json()['code'] == 200
|
|
assert db.query(SysRoleMenu).filter_by(role_id='role', tenant_no='t', del_flag=0).count() == 4
|
|
|
|
|
|
@pytest.mark.parametrize('super_admin', [False, True])
|
|
@pytest.mark.parametrize('path,body', [
|
|
('/role-management/foreign-role/menus', {'menuIds': ['all']}),
|
|
('/role-management/role/menus', {'menuIds': ['all', 'foreign-menu']}),
|
|
('/role-management/foreign-role', {'roleName': 'Changed'}),
|
|
('/role-management/foreign-role/data-scope', {'dataScope': '1'}),
|
|
('/menus/foreign-menu', {'perms': 'project:project:all'}),
|
|
('/menus/project', {'parentId': 'foreign-menu'}),
|
|
('/posts/foreign-post', {'status': '1'}),
|
|
('/users/foreign-user', {'roleIds': ['role']}),
|
|
('/users/u', {'roleIds': ['foreign-role']}),
|
|
('/users/u', {'postIds': ['foreign-post']}),
|
|
])
|
|
def test_permission_admin_cannot_write_foreign_targets_or_links(env, path, body, super_admin):
|
|
db, user, client = env
|
|
for code in ('system:role:list', 'system:menu:list', 'system:post:list', 'system:user:list'):
|
|
grant(db, code)
|
|
if super_admin:
|
|
user['role'] = 'super_admin'
|
|
before = {r.menu_id for r in db.query(SysRoleMenu).filter_by(role_id='role', del_flag=0)}
|
|
result = client.put('/web/admin' + path, json=body)
|
|
assert result.json().get('code', result.status_code) in (400, 403, 404)
|
|
db.expire_all()
|
|
assert {r.menu_id for r in db.query(SysRoleMenu).filter_by(role_id='role', del_flag=0)} == before
|
|
assert db.get(SysRole, 'foreign-role').role_name == 'Foreign'
|
|
assert db.get(SysUser, 'foreign-user').tenant_no == 'foreign'
|
|
|
|
|
|
@pytest.mark.parametrize('parent', ['missing', 'all'])
|
|
def test_role_grant_rejects_broken_or_cyclic_ancestry_atomically(env, parent):
|
|
db, user, client = env
|
|
user['role'] = 'super_admin'
|
|
db.get(SysMenu, 'project').parent_id = parent
|
|
db.commit()
|
|
result = client.put('/web/admin/role-management/role/menus', json={'menuIds': ['all']})
|
|
assert result.json().get('code', result.status_code) == 400
|
|
assert db.query(SysRoleMenu).count() == 0
|
|
|
|
@pytest.mark.parametrize('code,path,body', [
|
|
('system:role:list', '/role-management/role', {'remark': 'authorized'}),
|
|
('system:role:list', '/role-management/role/data-scope', {'dataScope': '4'}),
|
|
('system:user:list', '/users/u', {'roleIds': ['role'], 'postIds': ['post']}),
|
|
('system:post:list', '/posts/post/roles', {'roleIds': ['role']}),
|
|
('system:post:list', '/posts/post/users', {'userIds': ['u']}),
|
|
('system:menu:list', '/menus/all', {'menuName': '全部项目'}),
|
|
])
|
|
def test_authorized_tenant_administrators_can_manage_their_module(env, code, path, body):
|
|
db, _, client = env
|
|
grant(db, code)
|
|
assert client.put('/web/admin' + path, json=body).json()['code'] == 200
|
|
|
|
|
|
def test_web_and_mobile_ancestor_roots_remain_separate(env):
|
|
db, user, client = env
|
|
user['role'] = 'super_admin'
|
|
db.add_all([
|
|
SysMenu(id='mobile-root', tenant_no='t', parent_id='0', menu_name='Mobile'),
|
|
SysMenu(id='mobile-leaf', tenant_no='t', parent_id='mobile-root', menu_name='Mobile business'),
|
|
])
|
|
db.commit()
|
|
assert client.put('/web/admin/role-management/role/menus', json={'menuIds': ['all', 'mobile-leaf']}).json()['code'] == 200
|
|
assert {r.menu_id for r in db.query(SysRoleMenu).filter_by(role_id='role', del_flag=0)} == {'all', 'project', 'root', 'mobile-leaf', 'mobile-root'}
|
|
|
|
@pytest.mark.parametrize('method,path,body', [
|
|
('POST', '/qc-material-amount-auth/users', {'userId': 'u'}),
|
|
('DELETE', '/qc-material-amount-auth/users/u', None),
|
|
('POST', '/qc-material-amount-auth/users/batch-revoke', {'userIds': ['u']}),
|
|
])
|
|
def test_business_admin_cannot_self_bind_specialized_authorization_roles(env, method, path, body):
|
|
from app.api.qc import router as qc_router
|
|
_, _, client = env
|
|
client.app.include_router(qc_router)
|
|
assert client.request(method, '/web/admin' + path, json=body).status_code == 403
|
|
|
|
|
|
def test_specialized_authorization_manager_can_grant_and_revoke(env):
|
|
from app.api.qc import router as qc_router
|
|
from app.core.qc_material_amount_auth_constants import (
|
|
QC_MATERIAL_AMOUNT_AUTH_LIST_PERM, QC_MATERIAL_AMOUNT_VIEW_ROLE_ID,
|
|
QC_MATERIAL_AMOUNT_VIEW_ROLE_NAME, QC_MATERIAL_AMOUNT_AUTH_DEPT_NAME,
|
|
)
|
|
from app.models.models import SysDept
|
|
db, _, client = env
|
|
client.app.include_router(qc_router)
|
|
grant(db, QC_MATERIAL_AMOUNT_AUTH_LIST_PERM)
|
|
db.add_all([
|
|
SysDept(id='qc-dept', tenant_no='t', dept_name=QC_MATERIAL_AMOUNT_AUTH_DEPT_NAME),
|
|
SysRole(id=QC_MATERIAL_AMOUNT_VIEW_ROLE_ID, tenant_no='t', role_name=QC_MATERIAL_AMOUNT_VIEW_ROLE_NAME),
|
|
])
|
|
db.get(SysUser, 'u').dept_id = 'qc-dept'
|
|
db.commit()
|
|
assert client.post('/web/admin/qc-material-amount-auth/users', json={'userId': 'u'}).json()['code'] == 200
|
|
assert client.delete('/web/admin/qc-material-amount-auth/users/u').json()['code'] == 200
|