diff --git a/.gitignore b/.gitignore index 386939c..84a3bfd 100755 --- a/.gitignore +++ b/.gitignore @@ -41,7 +41,7 @@ nosetests.xml acl-api/test-output # Translations -*.mo +#*.mo # Mr Developer .mr.developer.cfg diff --git a/README.md b/README.md index 50ac63e..b8fccab 100644 --- a/README.md +++ b/README.md @@ -58,5 +58,7 @@ docker-compose up -d _**欢迎关注公众号(维易科技OneOps),关注后可加入微信群,进行产品和技术交流。**_ -![公众号: 维易科技OneOps](docs/images/wechat.png) +

+ 公众号: 维易科技OneOps +

\ No newline at end of file diff --git a/acl-api/Pipfile b/acl-api/Pipfile index 901072e..b1ed240 100644 --- a/acl-api/Pipfile +++ b/acl-api/Pipfile @@ -5,8 +5,8 @@ name = "pypi" [packages] # Flask -Flask = "==2.3.2" -Werkzeug = "==2.3.6" +Flask = "==2.2.5" +Werkzeug = "==2.2.3" click = ">=5.0" # Api Flask-RESTful = "==0.3.10" @@ -15,6 +15,7 @@ Flask-SQLAlchemy = "==2.5.0" SQLAlchemy = "==1.4.49" PyMySQL = "==1.1.0" redis = "==4.6.0" +python-redis-lock = "==4.0.0" # Migrations Flask-Migrate = "==2.5.2" # Deployment @@ -24,10 +25,13 @@ supervisor = "==4.0.3" Flask-Login = "==0.6.2" Flask-Bcrypt = "==1.0.1" Flask-Cors = ">=3.0.8" -python-ldap = "==3.4.0" +ldap3 = "==2.9.1" pycryptodome = "==3.12.0" +lz4 = ">=4.3.2" # Caching Flask-Caching = ">=1.0.0" +# i18n +flask-babel = "==4.0.0" # Environment variable parsing environs = "==4.2.0" marshmallow = "==2.20.2" @@ -56,6 +60,7 @@ Jinja2 = "==3.1.2" jinja2schema = "==0.1.4" msgpack-python = "==0.5.6" alembic = "==1.7.7" +python-magic = "==0.4.27" [dev-packages] # Testing diff --git a/acl-api/api/app.py b/acl-api/api/app.py index 91eb2c5..8cc1ab4 100644 --- a/acl-api/api/app.py +++ b/acl-api/api/app.py @@ -4,33 +4,29 @@ import decimal import logging import os -import sys from inspect import getmembers from logging.handlers import RotatingFileHandler +from pathlib import Path +import sys from flask import Flask -from flask import make_response, jsonify +from flask import jsonify +from flask import make_response +from flask import request from flask.blueprints import Blueprint from flask.cli import click from flask.json.provider import DefaultJSONProvider +from flask_babel.speaklater import LazyString import api.views.entry -from api.extensions import ( - bcrypt, - cors, - cache, - db, - login_manager, - migrate, - celery, - rd, -) -from api.flask_cas import CAS +from api.extensions import (bcrypt, babel, cache, celery, cors, db, login_manager, migrate, rd) +from api.lib.perm.authentication.cas import CAS +from api.lib.perm.authentication.oauth2 import OAuth2 from api.models.acl import User HERE = os.path.abspath(os.path.dirname(__file__)) PROJECT_ROOT = os.path.join(HERE, os.pardir) -API_PACKAGE = "api" +BASE_DIR = Path(__file__).resolve().parent.parent @login_manager.user_loader @@ -76,7 +72,7 @@ def __call__(self, environ, start_response): class MyJSONEncoder(DefaultJSONProvider): def default(self, o): - if isinstance(o, (decimal.Decimal, datetime.date, datetime.time)): + if isinstance(o, (decimal.Decimal, datetime.date, datetime.time, LazyString)): return str(o) if isinstance(o, datetime.datetime): @@ -85,15 +81,6 @@ def default(self, o): return o -def create_acl_app(config_object="settings"): - app = Flask(__name__.split(".")[0]) - app.config.from_object(config_object) - - register_extensions(app) - - return app - - def create_app(config_object="settings"): """Create application factory, as explained here: http://flask.pocoo.org/docs/patterns/appfactories/. @@ -110,6 +97,7 @@ def create_app(config_object="settings"): register_shell_context(app) register_commands(app) CAS(app) + OAuth2(app) app.wsgi_app = ReverseProxy(app.wsgi_app) configure_upload_dir(app) @@ -129,12 +117,18 @@ def configure_upload_dir(app): def register_extensions(app): """Register Flask extensions.""" + + def get_locale(): + accept_languages = app.config.get('ACCEPT_LANGUAGES', ['en', 'zh']) + return request.accept_languages.best_match(accept_languages) + bcrypt.init_app(app) + babel.init_app(app, locale_selector=get_locale) cache.init_app(app) db.init_app(app) cors.init_app(app) login_manager.init_app(app) - migrate.init_app(app, db) + migrate.init_app(app, db, directory=f"{BASE_DIR}/migrations") rd.init_app(app) app.config.update(app.config.get("CELERY")) @@ -157,10 +151,8 @@ def render_error(error): error_code = getattr(error, "code", 500) if not str(error_code).isdigit(): error_code = 400 - if error_code != 500: - return make_response(jsonify(message=str(error)), error_code) - else: - return make_response(jsonify(message=traceback.format_exc(-1)), error_code) + + return make_response(jsonify(message=str(error)), error_code) for errcode in app.config.get("ERROR_CODES") or [400, 401, 403, 404, 405, 500, 502]: app.errorhandler(errcode)(render_error) @@ -183,9 +175,8 @@ def register_commands(app): for root, _, files in os.walk(os.path.join(HERE, "commands")): for filename in files: if not filename.startswith("_") and filename.endswith("py"): - module_path = os.path.join(API_PACKAGE, root[root.index("commands"):]) - if module_path not in sys.path: - sys.path.insert(1, module_path) + if root not in sys.path: + sys.path.insert(1, root) command = __import__(os.path.splitext(filename)[0]) func_list = [o[0] for o in getmembers(command) if isinstance(o[1], click.core.Command)] for func_name in func_list: @@ -203,10 +194,11 @@ def configure_logger(app): app.logger.addHandler(handler) log_file = app.config['LOG_PATH'] - file_handler = RotatingFileHandler(log_file, - maxBytes=2 ** 30, - backupCount=7) - file_handler.setLevel(getattr(logging, app.config['LOG_LEVEL'])) - file_handler.setFormatter(formatter) - app.logger.addHandler(file_handler) + if log_file and log_file != "/dev/stdout": + file_handler = RotatingFileHandler(log_file, + maxBytes=2 ** 30, + backupCount=7) + file_handler.setLevel(getattr(logging, app.config['LOG_LEVEL'])) + file_handler.setFormatter(formatter) + app.logger.addHandler(file_handler) app.logger.setLevel(getattr(logging, app.config['LOG_LEVEL'])) diff --git a/acl-api/api/commands/click_acl.py b/acl-api/api/commands/click_acl.py index d35c6a3..75303ca 100644 --- a/acl-api/api/commands/click_acl.py +++ b/acl-api/api/commands/click_acl.py @@ -1,10 +1,15 @@ import click from flask.cli import with_appcontext +from api.lib.perm.acl.user import UserCRUD + @click.command() @with_appcontext def init_acl(): + """ + acl init + """ from api.models.acl import Role from api.models.acl import App from api.tasks.acl import role_rebuild @@ -20,50 +25,32 @@ def init_acl(): role_rebuild.apply_async(args=(role.id, app.id), queue=ACL_QUEUE) -# @click.command() -# @with_appcontext -# def acl_clean(): -# from api.models.acl import Resource -# from api.models.acl import Permission -# from api.models.acl import RolePermission -# -# perms = RolePermission.get_by(to_dict=False) -# -# for r in perms: -# perm = Permission.get_by_id(r.perm_id) -# if perm and perm.app_id != r.app_id: -# resource_id = r.resource_id -# resource = Resource.get_by_id(resource_id) -# perm_name = perm.name -# existed = Permission.get_by(resource_type_id=resource.resource_type_id, name=perm_name, first=True, -# to_dict=False) -# if existed is not None: -# other = RolePermission.get_by(rid=r.rid, perm_id=existed.id, resource_id=resource_id) -# if not other: -# r.update(perm_id=existed.id) -# else: -# r.soft_delete() -# else: -# r.soft_delete() -# -# -# @click.command() -# @with_appcontext -# def acl_has_resource_role(): -# from api.models.acl import Role -# from api.models.acl import App -# from api.lib.perm.acl.cache import HasResourceRoleCache -# from api.lib.perm.acl.role import RoleCRUD -# -# roles = Role.get_by(to_dict=False) -# apps = App.get_by(to_dict=False) -# for role in roles: -# if role.app_id: -# res = RoleCRUD.recursive_resources(role.id, role.app_id) -# if res.get('resources') or res.get('groups'): -# HasResourceRoleCache.add(role.id, role.app_id) -# else: -# for app in apps: -# res = RoleCRUD.recursive_resources(role.id, app.id) -# if res.get('resources') or res.get('groups'): -# HasResourceRoleCache.add(role.id, app.id) +@click.command() +@with_appcontext +def add_user(): + """ + create a user + + is_admin: default is False + + """ + + from api.models.acl import App + from api.lib.perm.acl.cache import AppCache + from api.lib.perm.acl.cache import RoleCache + from api.lib.perm.acl.role import RoleCRUD + from api.lib.perm.acl.role import RoleRelationCRUD + + username = click.prompt('Enter username', confirmation_prompt=False) + password = click.prompt('Enter password', hide_input=True, confirmation_prompt=True) + email = click.prompt('Enter email ', confirmation_prompt=False) + is_admin = click.prompt('Admin (Y/N) ', confirmation_prompt=False, type=bool, default=False) + + UserCRUD.add(username=username, password=password, email=email) + + if is_admin: + app = AppCache.get('acl') or App.create(name='acl') + acl_admin = RoleCache.get_by_name(app.id, 'acl_admin') or RoleCRUD.add_role('acl_admin', app.id, True) + rid = RoleCache.get_by_name(None, username).id + + RoleRelationCRUD.add(acl_admin, acl_admin.id, [rid], app.id) diff --git a/acl-api/api/commands/init_common_setting.py b/acl-api/api/commands/click_common_setting.py similarity index 72% rename from acl-api/api/commands/init_common_setting.py rename to acl-api/api/commands/click_common_setting.py index ea5afc9..30ddbcf 100644 --- a/acl-api/api/commands/init_common_setting.py +++ b/acl-api/api/commands/click_common_setting.py @@ -4,15 +4,13 @@ from werkzeug.datastructures import MultiDict from api.lib.common_setting.acl import ACLManager -from api.lib.common_setting.employee import EmployeeAddForm +from api.lib.common_setting.employee import EmployeeAddForm, GrantEmployeeACLPerm from api.lib.common_setting.resp_format import ErrFormat +from api.lib.common_setting.utils import CheckNewColumn from api.models.common_setting import Employee, Department class InitEmployee(object): - """ - 初始化员工 - """ def __init__(self): self.log = current_app.logger @@ -58,7 +56,8 @@ def import_user_from_acl(self): self.log.error(ErrFormat.acl_import_user_failed.format(user['username'], str(e))) self.log.error(e) - def get_rid_by_uid(self, uid): + @staticmethod + def get_rid_by_uid(uid): from api.models.acl import Role role = Role.get_by(first=True, uid=uid) return role['id'] if role is not None else 0 @@ -71,7 +70,8 @@ def __init__(self): def init(self): self.init_wide_company() - def hard_delete(self, department_id, department_name): + @staticmethod + def hard_delete(department_id, department_name): existed_deleted_list = Department.query.filter( Department.department_name == department_name, Department.department_id == department_id, @@ -80,11 +80,12 @@ def hard_delete(self, department_id, department_name): for existed in existed_deleted_list: existed.delete() - def get_department(self, department_name): + @staticmethod + def get_department(department_name): return Department.query.filter( Department.department_name == department_name, Department.deleted == 0, - ).order_by(Department.created_at.asc()).first() + ).first() def run(self, department_id, department_name, department_parent_id): self.hard_delete(department_id, department_name) @@ -94,7 +95,7 @@ def run(self, department_id, department_name, department_parent_id): if res.department_id == department_id: return else: - new_d = res.update( + res.update( department_id=department_id, department_parent_id=department_parent_id, ) @@ -108,11 +109,11 @@ def run(self, department_id, department_name, department_parent_id): new_d = self.get_department(department_name) if new_d.department_id != department_id: - new_d = new_d.update( + new_d.update( department_id=department_id, department_parent_id=department_parent_id, ) - self.log.info(f"初始化 {department_name} 部门成功.") + self.log.info(f"init {department_name} success.") def run_common(self, department_id, department_name, department_parent_id): try: @@ -123,19 +124,14 @@ def run_common(self, department_id, department_name, department_parent_id): raise Exception(e) def init_wide_company(self): - """ - 创建 id 0, name 全公司 的部门 - """ department_id = 0 department_name = '全公司' department_parent_id = -1 self.run_common(department_id, department_name, department_parent_id) - def create_acl_role_with_department(self): - """ - 当前所有部门,在ACL创建 role - """ + @staticmethod + def create_acl_role_with_department(): acl = ACLManager('acl') role_name_map = {role['name']: role for role in acl.get_all_roles()} @@ -146,7 +142,7 @@ def create_acl_role_with_department(self): continue role = role_name_map.get(department.department_name) - if role is None: + if not role: payload = { 'app_id': 'acl', 'name': department.department_name, @@ -161,6 +157,31 @@ def create_acl_role_with_department(self): info = f"update department acl_rid: {acl_rid}" current_app.logger.info(info) + def init_backend_resource(self): + acl = self.check_app('backend') + acl_rid = self.get_admin_user_rid() + + if acl_rid == 0: + return + GrantEmployeeACLPerm(acl).grant_by_rid(acl_rid, True) + + @staticmethod + def check_app(app_name): + acl = ACLManager(app_name) + payload = dict( + name=app_name, + description=app_name + ) + app = acl.validate_app() + if not app: + acl.create_app(payload) + return acl + + @staticmethod + def get_admin_user_rid(): + admin = Employee.get_by(first=True, username='admin', to_dict=False) + return admin.acl_rid if admin else 0 + @click.command() @with_appcontext @@ -177,5 +198,33 @@ def init_department(): """ Department initialization """ - InitDepartment().init() - InitDepartment().create_acl_role_with_department() + cli = InitDepartment() + cli.init_wide_company() + cli.create_acl_role_with_department() + cli.init_backend_resource() + + +@click.command() +@with_appcontext +def common_check_new_columns(): + """ + add new columns to tables + """ + CheckNewColumn().run() + + +@click.command() +@with_appcontext +def common_sync_file_to_db(): + from api.lib.common_setting.upload_file import CommonFileCRUD + CommonFileCRUD.sync_file_to_db() + + +@click.command() +@with_appcontext +@click.option('--value', type=click.INT, default=-1) +def set_auth_auto_redirect_enable(value): + if value < 0: + return + from api.lib.common_setting.common_data import CommonDataCRUD + CommonDataCRUD.set_auth_auto_redirect_enable(value) diff --git a/acl-api/api/commands/common.py b/acl-api/api/commands/common.py index 1d10f1c..3fd9f7b 100644 --- a/acl-api/api/commands/common.py +++ b/acl-api/api/commands/common.py @@ -150,3 +150,40 @@ def db_setup(): """create tables """ db.create_all() + + +@click.group() +def translate(): + """Translation and localization commands.""" + + +@translate.command() +@click.argument('lang') +def init(lang): + """Initialize a new language.""" + + if os.system('pybabel extract -F babel.cfg -k _l -o messages.pot .'): + raise RuntimeError('extract command failed') + if os.system( + 'pybabel init -i messages.pot -d api/translations -l ' + lang): + raise RuntimeError('init command failed') + os.remove('messages.pot') + + +@translate.command() +def update(): + """Update all languages.""" + + if os.system('pybabel extract -F babel.cfg -k _l -o messages.pot .'): + raise RuntimeError('extract command failed') + if os.system('pybabel update -i messages.pot -d api/translations'): + raise RuntimeError('update command failed') + os.remove('messages.pot') + + +@translate.command() +def compile(): + """Compile all languages.""" + + if os.system('pybabel compile -d api/translations'): + raise RuntimeError('compile command failed') diff --git a/acl-api/api/extensions.py b/acl-api/api/extensions.py index 6bffa91..85fc2d1 100644 --- a/acl-api/api/extensions.py +++ b/acl-api/api/extensions.py @@ -2,6 +2,7 @@ from celery import Celery +from flask_babel import Babel from flask_bcrypt import Bcrypt from flask_caching import Cache from flask_cors import CORS @@ -12,6 +13,7 @@ from api.lib.utils import RedisHandler bcrypt = Bcrypt() +babel = Babel() login_manager = LoginManager() db = SQLAlchemy(session_options={"autoflush": False}) migrate = Migrate() diff --git a/acl-api/api/lib/common_setting/acl.py b/acl-api/api/lib/common_setting/acl.py index 163a373..5591bc0 100644 --- a/acl-api/api/lib/common_setting/acl.py +++ b/acl-api/api/lib/common_setting/acl.py @@ -1,13 +1,20 @@ # -*- coding:utf-8 -*- -from flask import abort from flask import current_app from api.lib.common_setting.resp_format import ErrFormat +from api.lib.perm.acl.app import AppCRUD from api.lib.perm.acl.cache import RoleCache, AppCache +from api.lib.perm.acl.permission import PermissionCRUD +from api.lib.perm.acl.resource import ResourceTypeCRUD, ResourceCRUD from api.lib.perm.acl.role import RoleCRUD, RoleRelationCRUD from api.lib.perm.acl.user import UserCRUD +def validate_app(app_id): + app = AppCache.get(app_id) + return app.id if app else None + + class ACLManager(object): def __init__(self, app_name='acl', uid=None): self.log = current_app.logger @@ -78,19 +85,69 @@ def edit_role(_id, payload): return role.to_dict() @staticmethod - def delete_role(_id, payload): + def delete_role(_id): RoleCRUD.delete_role(_id) return dict(rid=_id) def get_user_info(self, username): from api.lib.perm.acl.acl import ACLManager as ACL user_info = ACL().get_user_info(username, self.app_name) - result = dict(name=user_info.get('nickname') or username, - username=user_info.get('username') or username, - email=user_info.get('email'), - uid=user_info.get('uid'), - rid=user_info.get('rid'), - role=dict(permissions=user_info.get('parents')), - avatar=user_info.get('avatar')) + result = dict( + name=user_info.get('nickname') or username, + username=user_info.get('username') or username, + email=user_info.get('email'), + uid=user_info.get('uid'), + rid=user_info.get('rid'), + role=dict(permissions=user_info.get('parents')), + avatar=user_info.get('avatar') + ) return result + + def validate_app(self): + return AppCache.get(self.app_name) + + def get_all_resources_types(self, q=None, page=1, page_size=999999): + app_id = self.validate_app().id + numfound, res, id2perms = ResourceTypeCRUD.search(q, app_id, page, page_size) + + return dict( + numfound=numfound, + groups=[i.to_dict() for i in res], + id2perms=id2perms + ) + + def create_resources_type(self, payload): + payload['app_id'] = self.validate_app().id + rt = ResourceTypeCRUD.add(**payload) + + return rt.to_dict() + + def update_resources_type(self, _id, payload): + rt = ResourceTypeCRUD.update(_id, **payload) + + return rt.to_dict() + + def create_resource(self, payload): + payload['app_id'] = self.validate_app().id + resource = ResourceCRUD.add(**payload) + + return resource.to_dict() + + def get_resource_by_type(self, q, u, rt_id, page=1, page_size=999999): + numfound, res = ResourceCRUD.search(q, u, self.validate_app().id, rt_id, page, page_size) + return res + + @staticmethod + def grant_resource(rid, resource_id, perms): + PermissionCRUD.grant(rid, perms, resource_id=resource_id, group_id=None) + + @staticmethod + def create_app(payload): + rt = AppCRUD.add(**payload) + + return rt.to_dict() + + def role_has_perms(self, rid, resource_name, resource_type_name, perm): + app_id = validate_app(self.app_name) + return RoleCRUD.has_permission(rid, resource_name, resource_type_name, app_id, perm) diff --git a/acl-api/api/lib/common_setting/common_data.py b/acl-api/api/lib/common_setting/common_data.py new file mode 100644 index 0000000..93c6f6d --- /dev/null +++ b/acl-api/api/lib/common_setting/common_data.py @@ -0,0 +1,282 @@ +import copy +import json + +from flask import abort, current_app +from ldap3 import Connection +from ldap3 import Server +from ldap3.core.exceptions import LDAPBindError, LDAPSocketOpenError +from ldap3 import AUTO_BIND_NO_TLS + +from api.extensions import db +from api.lib.common_setting.resp_format import ErrFormat +from api.models.common_setting import CommonData +from api.lib.utils import AESCrypto +from api.lib.common_setting.const import AuthCommonConfig, AuthenticateType, AuthCommonConfigAutoRedirect, TestType + + +class CommonDataCRUD(object): + + @staticmethod + def get_data_by_type(data_type): + CommonDataCRUD.check_auth_type(data_type) + return CommonData.get_by(data_type=data_type) + + @staticmethod + def get_data_by_id(_id, to_dict=True): + return CommonData.get_by(first=True, id=_id, to_dict=to_dict) + + @staticmethod + def create_new_data(data_type, **kwargs): + try: + CommonDataCRUD.check_auth_type(data_type) + + return CommonData.create(data_type=data_type, **kwargs) + except Exception as e: + db.session.rollback() + abort(400, str(e)) + + @staticmethod + def update_data(_id, **kwargs): + existed = CommonDataCRUD.get_data_by_id(_id, to_dict=False) + if not existed: + abort(404, ErrFormat.common_data_not_found.format(_id)) + try: + CommonDataCRUD.check_auth_type(existed.data_type) + return existed.update(**kwargs) + except Exception as e: + db.session.rollback() + abort(400, str(e)) + + @staticmethod + def delete(_id): + existed = CommonDataCRUD.get_data_by_id(_id, to_dict=False) + if not existed: + abort(404, ErrFormat.common_data_not_found.format(_id)) + try: + CommonDataCRUD.check_auth_type(existed.data_type) + existed.soft_delete() + except Exception as e: + db.session.rollback() + abort(400, str(e)) + + @staticmethod + def check_auth_type(data_type): + if data_type in list(AuthenticateType.all()) + [AuthCommonConfig]: + abort(400, ErrFormat.common_data_not_support_auth_type.format(data_type)) + + @staticmethod + def set_auth_auto_redirect_enable(_value: int): + existed = CommonData.get_by(first=True, data_type=AuthCommonConfig, to_dict=False) + if not existed: + CommonDataCRUD.create_new_data(AuthCommonConfig, data={AuthCommonConfigAutoRedirect: _value}) + else: + data = existed.data + data = copy.deepcopy(existed.data) if data else {} + data[AuthCommonConfigAutoRedirect] = _value + CommonDataCRUD.update_data(existed.id, data=data) + return True + + @staticmethod + def get_auth_auto_redirect_enable(): + existed = CommonData.get_by(first=True, data_type=AuthCommonConfig) + if not existed: + return 0 + data = existed.get('data', {}) + if not data: + return 0 + return data.get(AuthCommonConfigAutoRedirect, 0) + + +class AuthenticateDataCRUD(object): + common_type_list = [AuthCommonConfig] + + def __init__(self, _type): + self._type = _type + self.record = None + self.decrypt_data = {} + + def get_support_type_list(self): + return list(AuthenticateType.all()) + self.common_type_list + + def get(self): + if not self.decrypt_data: + self.decrypt_data = self.get_decrypt_data() + + return self.decrypt_data + + def get_by_key(self, _key): + if not self.decrypt_data: + self.decrypt_data = self.get_decrypt_data() + + return self.decrypt_data.get(_key, None) + + def get_record(self, to_dict=False) -> CommonData: + return CommonData.get_by(first=True, data_type=self._type, to_dict=to_dict) + + def get_record_with_decrypt(self) -> dict: + record = CommonData.get_by(first=True, data_type=self._type, to_dict=True) + if not record: + return {} + data = self.get_decrypt_dict(record.get('data', '')) + record['data'] = data + return record + + def get_decrypt_dict(self, data): + decrypt_str = self.decrypt(data) + try: + return json.loads(decrypt_str) + except Exception as e: + abort(400, str(e)) + + def get_decrypt_data(self) -> dict: + self.record = self.get_record() + if not self.record: + return self.get_from_config() + return self.get_decrypt_dict(self.record.data) + + def get_from_config(self): + return current_app.config.get(self._type, {}) + + def check_by_type(self) -> None: + existed = self.get_record() + if existed: + abort(400, ErrFormat.common_data_already_existed.format(self._type)) + + def create(self, data) -> CommonData: + self.check_by_type() + encrypt = data.pop('encrypt', None) + if encrypt is False: + return CommonData.create(data_type=self._type, data=data) + encrypted_data = self.encrypt(data) + try: + return CommonData.create(data_type=self._type, data=encrypted_data) + except Exception as e: + db.session.rollback() + abort(400, str(e)) + + def update_by_record(self, record, data) -> CommonData: + encrypt = data.pop('encrypt', None) + if encrypt is False: + return record.update(data=data) + encrypted_data = self.encrypt(data) + try: + return record.update(data=encrypted_data) + except Exception as e: + db.session.rollback() + abort(400, str(e)) + + def update(self, _id, data) -> CommonData: + existed = CommonData.get_by(first=True, to_dict=False, id=_id) + if not existed: + abort(404, ErrFormat.common_data_not_found.format(_id)) + + return self.update_by_record(existed, data) + + @staticmethod + def delete(_id) -> None: + existed = CommonData.get_by(first=True, to_dict=False, id=_id) + if not existed: + abort(404, ErrFormat.common_data_not_found.format(_id)) + try: + existed.soft_delete() + except Exception as e: + db.session.rollback() + abort(400, str(e)) + + @staticmethod + def encrypt(data) -> str: + if type(data) is dict: + try: + data = json.dumps(data) + except Exception as e: + abort(400, str(e)) + return AESCrypto().encrypt(data) + + @staticmethod + def decrypt(data) -> str: + return AESCrypto().decrypt(data) + + @staticmethod + def get_enable_list(): + all_records = CommonData.query.filter( + CommonData.data_type.in_(AuthenticateType.all()), + CommonData.deleted == 0 + ).all() + enable_list = [] + for auth_type in AuthenticateType.all(): + record = list(filter(lambda x: x.data_type == auth_type, all_records)) + if not record: + config = current_app.config.get(auth_type, None) + if not config: + continue + + if config.get('enable', False): + enable_list.append(dict( + auth_type=auth_type, + )) + + continue + + try: + decrypt_data = json.loads(AuthenticateDataCRUD.decrypt(record[0].data)) + except Exception as e: + current_app.logger.error(e) + continue + + if decrypt_data.get('enable', 0) == 1: + enable_list.append(dict( + auth_type=auth_type, + )) + + auth_auto_redirect = CommonDataCRUD.get_auth_auto_redirect_enable() + + return dict( + enable_list=enable_list, + auth_auto_redirect=auth_auto_redirect, + ) + + def test(self, test_type, data): + type_lower = self._type.lower() + func_name = f'test_{type_lower}' + if hasattr(self, func_name): + try: + return getattr(self, f'test_{type_lower}')(test_type, data) + except Exception as e: + abort(400, str(e)) + abort(400, ErrFormat.not_support_test.format(self._type)) + + @staticmethod + def test_ldap(test_type, data): + ldap_server = data.get('ldap_server') + ldap_user_dn = data.get('ldap_user_dn', '{}') + + server = Server(ldap_server, connect_timeout=2) + if not server.check_availability(): + raise Exception(ErrFormat.ldap_server_connect_not_available) + else: + if test_type == TestType.Connect: + return True + + username = data.get('username', None) + if not username: + raise Exception(ErrFormat.ldap_test_username_required) + user = ldap_user_dn.format(username) + password = data.get('password', None) + + try: + Connection(server, user=user, password=password, auto_bind=AUTO_BIND_NO_TLS) + except LDAPBindError: + ldap_domain = data.get('ldap_domain') + user_with_domain = f"{username}@{ldap_domain}" + try: + Connection(server, user=user_with_domain, password=password, auto_bind=AUTO_BIND_NO_TLS) + except Exception as e: + raise Exception(ErrFormat.ldap_test_unknown_error.format(str(e))) + + except LDAPSocketOpenError: + raise Exception(ErrFormat.ldap_server_connect_timeout) + + except Exception as e: + raise Exception(ErrFormat.ldap_test_unknown_error.format(str(e))) + + return True diff --git a/acl-api/api/lib/common_setting/company_info.py b/acl-api/api/lib/common_setting/company_info.py index 861bb3f..7031a2f 100644 --- a/acl-api/api/lib/common_setting/company_info.py +++ b/acl-api/api/lib/common_setting/company_info.py @@ -1,5 +1,7 @@ # -*- coding:utf-8 -*- +from urllib.parse import urlparse +from api.extensions import cache from api.models.common_setting import CompanyInfo @@ -11,14 +13,51 @@ def get(): @staticmethod def create(**kwargs): - return CompanyInfo.create(**kwargs) + CompanyInfoCRUD.check_data(**kwargs) + res = CompanyInfo.create(**kwargs) + CompanyInfoCache.refresh(res.info) + return res @staticmethod def update(_id, **kwargs): kwargs.pop('id', None) existed = CompanyInfo.get_by_id(_id) if not existed: - return CompanyInfoCRUD.create(**kwargs) + existed = CompanyInfoCRUD.create(**kwargs) else: + CompanyInfoCRUD.check_data(**kwargs) existed = existed.update(**kwargs) - return existed + CompanyInfoCache.refresh(existed.info) + return existed + + @staticmethod + def check_data(**kwargs): + info = kwargs.get('info', {}) + info['messenger'] = CompanyInfoCRUD.check_messenger(info.get('messenger', None)) + + kwargs['info'] = info + + @staticmethod + def check_messenger(messenger): + if not messenger: + return messenger + + parsed_url = urlparse(messenger) + return f"{parsed_url.scheme}://{parsed_url.netloc}" + + +class CompanyInfoCache(object): + key = 'CompanyInfoCache::' + + @classmethod + def get(cls): + info = cache.get(cls.key) + if not info: + res = CompanyInfo.get_by(first=True) or {} + info = res.get('info', {}) + cache.set(cls.key, info) + return info + + @classmethod + def refresh(cls, info): + cache.set(cls.key, info) diff --git a/acl-api/api/lib/common_setting/const.py b/acl-api/api/lib/common_setting/const.py index 2768f56..a68dcbf 100644 --- a/acl-api/api/lib/common_setting/const.py +++ b/acl-api/api/lib/common_setting/const.py @@ -12,3 +12,55 @@ class OperatorType(BaseEnum): LESS_THAN = 6 IS_EMPTY = 7 IS_NOT_EMPTY = 8 + + +BotNameMap = { + 'wechatApp': 'wechatBot', + 'feishuApp': 'feishuBot', + 'dingdingApp': 'dingdingBot', +} + + +class AuthenticateType(BaseEnum): + CAS = 'CAS' + OAUTH2 = 'OAUTH2' + OIDC = 'OIDC' + LDAP = 'LDAP' + + +AuthCommonConfig = 'AuthCommonConfig' +AuthCommonConfigAutoRedirect = 'auto_redirect' + + +class TestType(BaseEnum): + Connect = 'connect' + Login = 'login' + + +MIMEExtMap = { + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': '.docx', + 'application/msword': '.doc', + 'application/vnd.ms-word.document.macroEnabled.12': '.docm', + 'application/vnd.ms-excel': '.xls', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': '.xlsx', + 'application/vnd.ms-excel.sheet.macroEnabled.12': '.xlsm', + 'application/vnd.ms-powerpoint': '.ppt', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation': '.pptx', + 'application/vnd.ms-powerpoint.presentation.macroEnabled.12': '.pptm', + 'application/zip': '.zip', + 'application/x-7z-compressed': '.7z', + 'application/json': '.json', + 'application/pdf': '.pdf', + 'image/png': '.png', + 'image/bmp': '.bmp', + 'image/prs.btif': '.btif', + 'image/gif': '.gif', + 'image/jpeg': '.jpg', + 'image/tiff': '.tif', + 'image/vnd.microsoft.icon': '.ico', + 'image/webp': '.webp', + 'image/svg+xml': '.svg', + 'image/vnd.adobe.photoshop': '.psd', + 'text/plain': '.txt', + 'text/csv': '.csv', +} diff --git a/acl-api/api/lib/common_setting/decorator.py b/acl-api/api/lib/common_setting/decorator.py new file mode 100644 index 0000000..30106e1 --- /dev/null +++ b/acl-api/api/lib/common_setting/decorator.py @@ -0,0 +1,38 @@ +import functools + +from flask import abort, session +from api.lib.common_setting.acl import ACLManager +from api.lib.common_setting.resp_format import ErrFormat +from api.lib.perm.acl.acl import is_app_admin + + +def perms_role_required(app_name, resource_type_name, resource_name, perm, role_name=None): + def decorator_perms_role_required(func): + @functools.wraps(func) + def wrapper_required(*args, **kwargs): + acl = ACLManager(app_name) + has_perms = False + try: + has_perms = acl.role_has_perms(session["acl"]['rid'], resource_name, resource_type_name, perm) + except Exception as e: + # resource_type not exist, continue check role + if role_name: + if role_name not in session.get("acl", {}).get("parentRoles", []) and not is_app_admin(app_name): + abort(403, ErrFormat.role_required.format(role_name)) + + return func(*args, **kwargs) + else: + abort(403, ErrFormat.resource_no_permission.format(resource_name, perm)) + + if not has_perms: + if role_name: + if role_name not in session.get("acl", {}).get("parentRoles", []) and not is_app_admin(app_name): + abort(403, ErrFormat.role_required.format(role_name)) + else: + abort(403, ErrFormat.resource_no_permission.format(resource_name, perm)) + + return func(*args, **kwargs) + + return wrapper_required + + return decorator_perms_role_required diff --git a/acl-api/api/lib/common_setting/department.py b/acl-api/api/lib/common_setting/department.py index 8da3c58..7d72bc6 100644 --- a/acl-api/api/lib/common_setting/department.py +++ b/acl-api/api/lib/common_setting/department.py @@ -1,6 +1,6 @@ # -*- coding:utf-8 -*- -from flask import abort +from flask import abort, current_app from treelib import Tree from wtforms import Form from wtforms import IntegerField @@ -9,6 +9,7 @@ from api.extensions import db from api.lib.common_setting.resp_format import ErrFormat +from api.lib.common_setting.acl import ACLManager from api.lib.perm.acl.role import RoleCRUD from api.models.common_setting import Department, Employee @@ -23,7 +24,15 @@ def get_all_department_list(to_dict=True): *criterion ).order_by(Department.department_id.asc()) results = query.all() - return [r.to_dict() for r in results] if to_dict else results + if to_dict: + datas = [] + for r in results: + d = r.to_dict() + if r.department_id == 0: + d['department_name'] = ErrFormat.company_wide + datas.append(d) + return datas + return results def get_all_employee_list(block=0, to_dict=True): @@ -100,6 +109,7 @@ def get_tree_departments(self): employees = self.get_employees_by_d_id(department_id) top_d['employees'] = employees + top_d['department_name'] = ErrFormat.company_wide if len(sub_deps) == 0: top_d[sub_departments_column_name] = [] d_list.append(top_d) @@ -152,6 +162,10 @@ class DepartmentForm(Form): class DepartmentCRUD(object): + @staticmethod + def get_department_by_id(d_id, to_dict=True): + return Department.get_by(first=True, department_id=d_id, to_dict=to_dict) + @staticmethod def add(**kwargs): DepartmentCRUD.check_department_name_unique(kwargs['department_name']) @@ -186,10 +200,11 @@ def check_department_parent_id_allow(d_id, department_parent_id): filter(lambda d: d['department_id'] == department_parent_id, allow_p_d_id_list)) if len(target) == 0: try: - d = Department.get_by( + dep = Department.get_by( first=True, to_dict=False, department_id=department_parent_id) - name = d.department_name if d else ErrFormat.department_id_not_found.format(department_parent_id) + name = dep.department_name if dep else ErrFormat.department_id_not_found.format(department_parent_id) except Exception as e: + current_app.logger.error(str(e)) name = ErrFormat.department_id_not_found.format(department_parent_id) abort(400, ErrFormat.cannot_to_be_parent_department.format(name)) @@ -240,7 +255,7 @@ def edit(_id, **kwargs): return abort(400, ErrFormat.acl_update_role_failed.format(str(e))) try: - existed.update(**kwargs) + return existed.update(**kwargs) except Exception as e: return abort(400, str(e)) @@ -253,7 +268,7 @@ def delete(_id): try: RoleCRUD.delete_role(existed.acl_rid) except Exception as e: - pass + current_app.logger.error(str(e)) return existed.soft_delete() @@ -268,7 +283,7 @@ def get_allow_parent_d_id_by(department_id): try: tree.remove_subtree(department_id) except Exception as e: - pass + current_app.logger.error(str(e)) [allow_d_id_list.append({'department_id': int(n.identifier), 'department_name': n.tag}) for n in tree.all_nodes()] @@ -307,6 +322,7 @@ def get_department_tree_list(): tree_list = [] for top_d in top_deps: + top_d['department_name'] = ErrFormat.company_wide tree = Tree() identifier_root = top_d['department_id'] tree.create_node( @@ -377,6 +393,9 @@ def get_departments_and_ids(department_parent_id, block): d['employee_count'] = len(list(filter(lambda e: e['department_id'] in d_ids, all_employee_list))) + if int(department_parent_id) == -1: + d['department_name'] = ErrFormat.company_wide + return all_departments, department_id_list @staticmethod @@ -390,6 +409,151 @@ def get_department_id_list_by_root(root_department_id, tree_list=None): [id_list.append(int(n.identifier)) for n in tmp_tree.all_nodes()] except Exception as e: - pass + current_app.logger.error(str(e)) return id_list + + +class EditDepartmentInACL(object): + + @staticmethod + def add_department_to_acl(department_id, op_uid): + db_department = DepartmentCRUD.get_department_by_id(department_id, to_dict=False) + if not db_department: + return + + from api.models.acl import Role + role = Role.get_by(first=True, name=db_department.department_name, app_id=None) + + acl = ACLManager('acl', str(op_uid)) + if role is None: + payload = { + 'app_id': 'acl', + 'name': db_department.department_name, + } + role = acl.create_role(payload) + + acl_rid = role.get('id') if role else 0 + + db_department.update( + acl_rid=acl_rid + ) + info = f"add_department_to_acl, acl_rid: {acl_rid}" + current_app.logger.info(info) + return info + + @staticmethod + def delete_department_from_acl(department_rids, op_uid): + acl = ACLManager('acl', str(op_uid)) + + result = [] + + for rid in department_rids: + try: + acl.delete_role(rid) + except Exception as e: + result.append(f"delete_department_in_acl, rid: {rid}, error: {e}") + continue + + return result + + @staticmethod + def edit_department_name_in_acl(d_rid: int, d_name: str, op_uid: int): + acl = ACLManager('acl', str(op_uid)) + payload = { + 'name': d_name + } + try: + acl.edit_role(d_rid, payload) + except Exception as e: + return f"edit_department_name_in_acl, rid: {d_rid}, error: {e}" + + return f"edit_department_name_in_acl, rid: {d_rid}, success" + + @classmethod + def remove_from_old_department_role(cls, e_list, acl): + result = [] + for employee in e_list: + employee_acl_rid = employee.get('e_acl_rid') + if employee_acl_rid == 0: + result.append(f"employee_acl_rid == 0") + continue + cls.remove_single_employee_from_old_department(acl, employee, result) + + @staticmethod + def remove_single_employee_from_old_department(acl, employee, result): + from api.models.acl import Role + old_department = DepartmentCRUD.get_department_by_id(employee.get('department_id'), False) + if not old_department: + return False + + old_role = Role.get_by(first=True, name=old_department.department_name, app_id=None) + old_d_rid_in_acl = old_role.get('id') if old_role else 0 + if old_d_rid_in_acl == 0: + return False + + d_acl_rid = old_department.acl_rid if old_d_rid_in_acl == old_department.acl_rid else old_d_rid_in_acl + payload = { + 'app_id': 'acl', + 'parent_id': d_acl_rid, + } + try: + acl.remove_user_from_role(employee.get('e_acl_rid'), payload) + current_app.logger.info(f"remove {employee.get('e_acl_rid')} from {d_acl_rid}") + except Exception as e: + result.append( + f"remove_user_from_role employee_acl_rid: {employee.get('e_acl_rid')}, parent_id: {d_acl_rid}, err: {e}") + + return True + + @staticmethod + def add_employee_to_new_department(acl, employee_acl_rid, new_department_acl_rid, result): + payload = { + 'app_id': 'acl', + 'child_ids': [employee_acl_rid], + } + try: + acl.add_user_to_role(new_department_acl_rid, payload) + current_app.logger.info(f"add {employee_acl_rid} to {new_department_acl_rid}") + except Exception as e: + result.append( + f"add_user_to_role employee_acl_rid: {employee_acl_rid}, parent_id: {new_department_acl_rid}, \ + err: {e}") + + @classmethod + def edit_employee_department_in_acl(cls, e_list: list, new_d_id: int, op_uid: int): + result = [] + new_department = DepartmentCRUD.get_department_by_id(new_d_id, False) + if not new_department: + result.append(f"{new_d_id} new_department is None") + return result + + from api.models.acl import Role + new_role = Role.get_by(first=True, name=new_department.department_name, app_id=None) + new_d_rid_in_acl = new_role.get('id') if new_role else 0 + acl = ACLManager('acl', str(op_uid)) + + if new_d_rid_in_acl == 0: + # only remove from old department role + cls.remove_from_old_department_role(e_list, acl) + return + + if new_d_rid_in_acl != new_department.acl_rid: + new_department.update( + acl_rid=new_d_rid_in_acl + ) + new_department_acl_rid = new_department.acl_rid if new_d_rid_in_acl == new_department.acl_rid else \ + new_d_rid_in_acl + + for employee in e_list: + employee_acl_rid = employee.get('e_acl_rid') + if employee_acl_rid == 0: + result.append(f"employee_acl_rid == 0") + continue + + cls.remove_single_employee_from_old_department(acl, employee, result) + + # 在新部门中添加员工 + cls.add_employee_to_new_department(acl, employee_acl_rid, new_department_acl_rid, result) + + return result diff --git a/acl-api/api/lib/common_setting/employee.py b/acl-api/api/lib/common_setting/employee.py index 72898f7..ca7173f 100644 --- a/acl-api/api/lib/common_setting/employee.py +++ b/acl-api/api/lib/common_setting/employee.py @@ -1,8 +1,9 @@ # -*- coding:utf-8 -*- - +import copy import traceback from datetime import datetime +import requests from flask import abort from flask_login import current_user from sqlalchemy import or_, literal_column, func, not_, and_ @@ -14,10 +15,13 @@ from api.extensions import db from api.lib.common_setting.acl import ACLManager -from api.lib.common_setting.const import COMMON_SETTING_QUEUE, OperatorType +from api.lib.common_setting.const import OperatorType +from api.lib.perm.acl.const import ACL_QUEUE from api.lib.common_setting.resp_format import ErrFormat from api.models.common_setting import Employee, Department +from api.tasks.common_setting import refresh_employee_acl_info, edit_employee_department_in_acl + acl_user_columns = [ 'email', 'mobile', @@ -120,10 +124,25 @@ def check_acl_user_and_create(user_info): employee = CreateEmployee().create_single(**data) return employee.to_dict() + @staticmethod + def add_employee_from_acl_created(**kwargs): + try: + kwargs['acl_uid'] = kwargs.pop('uid') + kwargs['acl_rid'] = kwargs.pop('rid') + kwargs['department_id'] = 0 + + Employee.create( + **kwargs + ) + except Exception as e: + abort(400, str(e)) + @staticmethod def add(**kwargs): try: - return CreateEmployee().create_single(**kwargs) + res = CreateEmployee().create_single(**kwargs) + refresh_employee_acl_info.apply_async(args=(res.employee_id,), queue=ACL_QUEUE) + return res except Exception as e: abort(400, str(e)) @@ -150,10 +169,9 @@ def update(_id, **kwargs): existed.update(**kwargs) if len(e_list) > 0: - from api.tasks.common_setting import edit_employee_department_in_acl edit_employee_department_in_acl.apply_async( args=(e_list, new_department_id, current_user.uid), - queue=COMMON_SETTING_QUEUE + queue=ACL_QUEUE ) return existed @@ -164,7 +182,7 @@ def update(_id, **kwargs): def edit_employee_by_uid(_uid, **kwargs): existed = EmployeeCRUD.get_employee_by_uid(_uid) try: - user = edit_acl_user(_uid, **kwargs) + edit_acl_user(_uid, **kwargs) for column in employee_pop_columns: if kwargs.get(column): @@ -176,9 +194,9 @@ def edit_employee_by_uid(_uid, **kwargs): @staticmethod def change_password_by_uid(_uid, password): - existed = EmployeeCRUD.get_employee_by_uid(_uid) + EmployeeCRUD.get_employee_by_uid(_uid) try: - user = edit_acl_user(_uid, password=password) + edit_acl_user(_uid, password=password) except Exception as e: return abort(400, str(e)) @@ -277,7 +295,9 @@ def get_employee_list_by_body(department_id, block_status, search='', order='', employees = [] for r in pagination.items: d = r.Employee.to_dict() - d['department_name'] = r.Department.department_name + d['department_name'] = r.Department.department_name if r.Department else '' + if r.Employee.department_id == 0: + d['department_name'] = ErrFormat.company_wide employees.append(d) return { @@ -345,9 +365,11 @@ def check_condition(column, operator, value, relation): if value and column == "last_login": try: - value = datetime.strptime(value, "%Y-%m-%d %H:%M:%S") + return datetime.strptime(value, "%Y-%m-%d %H:%M:%S") except Exception as e: - abort(400, ErrFormat.datetime_format_error.format(column)) + err = f"{ErrFormat.datetime_format_error.format(column)}: {str(e)}" + abort(400, err) + return value @staticmethod def get_attr_by_column(column): @@ -368,7 +390,7 @@ def get_query_by_conditions(query, conditions): relation = condition.get("relation", None) value = condition.get("value", None) - EmployeeCRUD.check_condition(column, operator, value, relation) + value = EmployeeCRUD.check_condition(column, operator, value, relation) a, o = EmployeeCRUD.get_expr_by_condition( column, operator, value, relation) and_list += a @@ -421,7 +443,7 @@ def get_employee_list_by(department_id, block_status, search='', order='', page= employees = [] for r in pagination.items: d = r.Employee.to_dict() - d['department_name'] = r.Department.department_name + d['department_name'] = r.Department.department_name if r.Department else '' employees.append(d) return { @@ -474,6 +496,224 @@ def get_employees_by_department_id(department_id, block): return [r.to_dict() for r in results] + @staticmethod + def remove_bind_notice_by_uid(_platform, _uid): + existed = EmployeeCRUD.get_employee_by_uid(_uid) + employee_data = existed.to_dict() + + notice_info = employee_data.get('notice_info', {}) + notice_info = copy.deepcopy(notice_info) if notice_info else {} + + notice_info[_platform] = '' + + existed.update( + notice_info=notice_info + ) + return ErrFormat.notice_remove_bind_success + + @staticmethod + def bind_notice_by_uid(_platform, _uid): + existed = EmployeeCRUD.get_employee_by_uid(_uid) + mobile = existed.mobile + if not mobile or len(mobile) == 0: + abort(400, ErrFormat.notice_bind_err_with_empty_mobile) + + from api.lib.common_setting.notice_config import NoticeConfigCRUD + messenger = NoticeConfigCRUD.get_messenger_url() + if not messenger or len(messenger) == 0: + abort(400, ErrFormat.notice_please_config_messenger_first) + + url = f"{messenger}/v1/uid/getbyphone" + try: + payload = dict( + phone=mobile, + sender=_platform + ) + res = requests.post(url, json=payload) + result = res.json() + if res.status_code != 200: + raise Exception(result.get('msg', '')) + target_id = result.get('uid', '') + + employee_data = existed.to_dict() + + notice_info = employee_data.get('notice_info', {}) + notice_info = copy.deepcopy(notice_info) if notice_info else {} + + notice_info[_platform] = '' if not target_id else target_id + + existed.update( + notice_info=notice_info + ) + return ErrFormat.notice_bind_success + + except Exception as e: + return abort(400, ErrFormat.notice_bind_failed.format(str(e))) + + @staticmethod + def get_employee_notice_by_ids(employee_ids): + criterion = [ + Employee.employee_id.in_(employee_ids), + Employee.deleted == 0, + ] + direct_columns = ['email', 'mobile'] + employees = Employee.query.filter( + *criterion + ).all() + results = [] + for employee in employees: + d = employee.to_dict() + tmp = dict( + employee_id=employee.employee_id, + ) + for column in direct_columns: + tmp[column] = d.get(column, '') + notice_info = d.get('notice_info', {}) + notice_info = copy.deepcopy(notice_info) if notice_info else {} + tmp.update(**notice_info) + results.append(tmp) + return results + + @staticmethod + def import_employee(employee_list): + res = CreateEmployee().batch_create(employee_list) + return res + + @staticmethod + def batch_edit_employee_department(employee_id_list, column_value): + err_list = [] + employee_list = [] + for _id in employee_id_list: + try: + existed = EmployeeCRUD.get_employee_by_id(_id) + employee = dict( + e_acl_rid=existed.acl_rid, + department_id=existed.department_id + ) + employee_list.append(employee) + existed.update(department_id=column_value) + + except Exception as e: + err_list.append({ + 'employee_id': _id, + 'err': str(e), + }) + from api.lib.common_setting.department import EditDepartmentInACL + EditDepartmentInACL.edit_employee_department_in_acl( + employee_list, column_value, current_user.uid + ) + return err_list + + @staticmethod + def batch_edit_password_or_block_column(column_name, employee_id_list, column_value, is_acl=False): + if column_name == 'block': + err_list = [] + success_list = [] + for _id in employee_id_list: + try: + employee = EmployeeCRUD.edit_employee_block_column( + _id, is_acl, **{column_name: column_value}) + success_list.append(employee) + except Exception as e: + err_list.append({ + 'employee_id': _id, + 'err': str(e), + }) + return err_list + else: + return EmployeeCRUD.batch_edit_column(column_name, employee_id_list, column_value, is_acl) + + @staticmethod + def batch_edit_column(column_name, employee_id_list, column_value, is_acl=False): + err_list = [] + for _id in employee_id_list: + try: + EmployeeCRUD.edit_employee_single_column( + _id, is_acl, **{column_name: column_value}) + except Exception as e: + err_list.append({ + 'employee_id': _id, + 'err': str(e), + }) + + return err_list + + @staticmethod + def edit_employee_single_column(_id, is_acl=False, **kwargs): + existed = EmployeeCRUD.get_employee_by_id(_id) + if 'direct_supervisor_id' in kwargs.keys(): + if kwargs['direct_supervisor_id'] == existed.direct_supervisor_id: + raise Exception(ErrFormat.direct_supervisor_is_not_self) + + if is_acl: + return edit_acl_user(existed.acl_uid, **kwargs) + + try: + for column in employee_pop_columns: + if kwargs.get(column): + kwargs.pop(column) + + return existed.update(**kwargs) + except Exception as e: + return abort(400, str(e)) + + @staticmethod + def edit_employee_block_column(_id, is_acl=False, **kwargs): + existed = EmployeeCRUD.get_employee_by_id(_id) + value = get_block_value(kwargs.get('block')) + if value is True: + check_department_director_id_or_direct_supervisor_id(_id) + value = 1 + else: + value = 0 + + if is_acl: + kwargs['block'] = value + edit_acl_user(existed.acl_uid, **kwargs) + + existed.update(block=value) + data = existed.to_dict() + return data + + @staticmethod + def batch_employee(column_name, column_value, employee_id_list): + if column_value is None: + abort(400, ErrFormat.value_is_required) + if column_name in ['password', 'block']: + return EmployeeCRUD.batch_edit_password_or_block_column(column_name, employee_id_list, column_value, True) + + elif column_name in ['department_id']: + return EmployeeCRUD.batch_edit_employee_department(employee_id_list, column_value) + + elif column_name in [ + 'direct_supervisor_id', 'position_name' + ]: + return EmployeeCRUD.batch_edit_column(column_name, employee_id_list, column_value, False) + + else: + abort(400, ErrFormat.column_name_not_support) + + @staticmethod + def update_last_login_by_uid(uid, last_login=None): + employee = Employee.get_by(acl_uid=uid, first=True, to_dict=False) + if not employee: + return + if last_login: + try: + last_login = datetime.strptime(last_login, '%Y-%m-%d %H:%M:%S') + except Exception as e: + last_login = datetime.now() + else: + last_login = datetime.now() + + try: + employee.update( + last_login=last_login + ) + return last_login + except Exception as e: + return + def get_user_map(key='uid', acl=None): """ @@ -514,6 +754,7 @@ def add_acl_user(self, **kwargs): try: existed = self.check_acl_user(user_data) if not existed: + user_data['add_from'] = 'common' return self.acl.create_user(user_data) return existed except Exception as e: @@ -546,11 +787,14 @@ def create_single_with_import(self, **kwargs): if existed: return existed - return Employee.create( + res = Employee.create( **kwargs ) + refresh_employee_acl_info.apply_async(args=(res.employee_id,), queue=ACL_QUEUE) + return res - def get_department_by_name(self, d_name): + @staticmethod + def get_department_by_name(d_name): return Department.get_by(first=True, department_name=d_name) def get_end_department_id(self, department_name_list, department_name_map): @@ -654,3 +898,75 @@ class EmployeeUpdateByUidForm(Form): avatar = StringField(validators=[]) sex = StringField(validators=[]) mobile = StringField(validators=[]) + + +class GrantEmployeeACLPerm(object): + """ + Grant ACL Permission After Create New Employee + """ + + def __init__(self, acl=None): + self.perms_by_create_resources_type = ['read', 'grant', 'delete', 'update'] + self.perms_by_common_grant = ['read'] + self.resource_name_list = ['公司信息', '公司架构', '通知设置'] + + self.acl = acl if acl else self.check_app('backend') + self.resources_types = self.acl.get_all_resources_types() + self.resources_type = self.get_resources_type() + self.resource_list = self.acl.get_resource_by_type(None, None, self.resources_type['id']) + + @staticmethod + def check_app(app_name): + acl = ACLManager(app_name) + payload = dict( + name=app_name, + description=app_name + ) + app = acl.validate_app() + if not app: + acl.create_app(payload) + return acl + + def get_resources_type(self): + results = list(filter(lambda t: t['name'] == '操作权限', self.resources_types['groups'])) + if len(results) == 0: + payload = dict( + app_id=self.acl.app_name, + name='操作权限', + description='', + perms=self.perms_by_create_resources_type + ) + resource_type = self.acl.create_resources_type(payload) + else: + resource_type = results[0] + resource_type_id = resource_type['id'] + existed_perms = self.resources_types.get('id2perms', {}).get(resource_type_id, []) + existed_perms = [p['name'] for p in existed_perms] + new_perms = [] + for perm in self.perms_by_create_resources_type: + if perm not in existed_perms: + new_perms.append(perm) + if len(new_perms) > 0: + resource_type['perms'] = existed_perms + new_perms + self.acl.update_resources_type(resource_type_id, resource_type) + + return resource_type + + def grant(self, rid_list): + [self.grant_by_rid(rid) for rid in rid_list if rid > 0] + + def grant_by_rid(self, rid, is_admin=False): + for name in self.resource_name_list: + resource = list(filter(lambda r: r['name'] == name, self.resource_list)) + if len(resource) == 0: + payload = dict( + type_id=self.resources_type['id'], + app_id=self.acl.app_name, + name=name, + ) + resource = self.acl.create_resource(payload) + else: + resource = resource[0] + + perms = self.perms_by_create_resources_type if is_admin else self.perms_by_common_grant + self.acl.grant_resource(rid, resource['id'], perms) diff --git a/acl-api/api/lib/common_setting/notice_config.py b/acl-api/api/lib/common_setting/notice_config.py new file mode 100644 index 0000000..152eb53 --- /dev/null +++ b/acl-api/api/lib/common_setting/notice_config.py @@ -0,0 +1,165 @@ +import requests + +from api.lib.common_setting.const import BotNameMap +from api.lib.common_setting.resp_format import ErrFormat +from api.models.common_setting import CompanyInfo, NoticeConfig +from wtforms import Form +from wtforms import StringField +from wtforms import validators +from flask import abort, current_app + + +class NoticeConfigCRUD(object): + + @staticmethod + def add_notice_config(**kwargs): + platform = kwargs.get('platform') + NoticeConfigCRUD.check_platform(platform) + info = kwargs.get('info', {}) + if 'name' not in info: + info['name'] = platform + kwargs['info'] = info + try: + NoticeConfigCRUD.update_messenger_config(**info) + res = NoticeConfig.create( + **kwargs + ) + return res + + except Exception as e: + return abort(400, str(e)) + + @staticmethod + def check_platform(platform): + NoticeConfig.get_by(first=True, to_dict=False, platform=platform) and \ + abort(400, ErrFormat.notice_platform_existed.format(platform)) + + @staticmethod + def edit_notice_config(_id, **kwargs): + existed = NoticeConfigCRUD.get_notice_config_by_id(_id) + try: + info = kwargs.get('info', {}) + if 'name' not in info: + info['name'] = existed.platform + kwargs['info'] = info + NoticeConfigCRUD.update_messenger_config(**info) + + res = existed.update(**kwargs) + return res + except Exception as e: + return abort(400, str(e)) + + @staticmethod + def get_messenger_url(): + from api.lib.common_setting.company_info import CompanyInfoCache + com_info = CompanyInfoCache.get() + if not com_info: + return + messenger = com_info.get('messenger', '') + if len(messenger) == 0: + return + if messenger[-1] == '/': + messenger = messenger[:-1] + return messenger + + @staticmethod + def update_messenger_config(**kwargs): + try: + messenger = NoticeConfigCRUD.get_messenger_url() + if not messenger or len(messenger) == 0: + raise Exception(ErrFormat.notice_please_config_messenger_first) + + url = f"{messenger}/v1/senders" + name = kwargs.get('name') + bot_list = kwargs.pop('bot', None) + for k, v in kwargs.items(): + if isinstance(v, bool): + kwargs[k] = 'true' if v else 'false' + else: + kwargs[k] = str(v) + + payload = {name: [kwargs]} + current_app.logger.info(f"update_messenger_config: {url}, {payload}") + res = requests.put(url, json=payload, timeout=2) + current_app.logger.info(f"update_messenger_config: {res.status_code}, {res.text}") + + if not bot_list or len(bot_list) == 0: + return + bot_name = BotNameMap.get(name) + payload = {bot_name: bot_list} + current_app.logger.info(f"update_messenger_config: {url}, {payload}") + bot_res = requests.put(url, json=payload, timeout=2) + current_app.logger.info(f"update_messenger_config: {bot_res.status_code}, {bot_res.text}") + + except Exception as e: + return abort(400, str(e)) + + @staticmethod + def get_notice_config_by_id(_id): + return NoticeConfig.get_by(first=True, to_dict=False, id=_id) or \ + abort(400, + ErrFormat.notice_not_existed.format(_id)) + + @staticmethod + def get_all(): + return NoticeConfig.get_by(to_dict=True) + + @staticmethod + def test_send_email(receive_address, **kwargs): + messenger = NoticeConfigCRUD.get_messenger_url() + if not messenger or len(messenger) == 0: + abort(400, ErrFormat.notice_please_config_messenger_first) + url = f"{messenger}/v1/message" + + recipient_email = receive_address + + subject = 'Test Email' + body = 'This is a test email' + payload = { + "sender": 'email', + "msgtype": "text/plain", + "title": subject, + "content": body, + "tos": [recipient_email], + } + current_app.logger.info(f"test_send_email: {url}, {payload}") + response = requests.post(url, json=payload) + if response.status_code != 200: + abort(400, response.text) + + return 1 + + @staticmethod + def get_app_bot(): + result = [] + for notice_app in NoticeConfig.get_by(to_dict=False): + if notice_app.platform in ['email']: + continue + info = notice_app.info + name = info.get('name', '') + if name not in BotNameMap: + continue + result.append(dict( + name=info.get('name', ''), + label=info.get('label', ''), + bot=info.get('bot', []), + )) + return result + + +class NoticeConfigForm(Form): + platform = StringField(validators=[ + validators.DataRequired(message="平台 不能为空"), + validators.Length(max=255), + ]) + info = StringField(validators=[ + validators.DataRequired(message="信息 不能为空"), + validators.Length(max=255), + ]) + + +class NoticeConfigUpdateForm(Form): + info = StringField(validators=[ + validators.DataRequired(message="信息 不能为空"), + validators.Length(max=255), + ]) diff --git a/acl-api/api/lib/common_setting/resp_format.py b/acl-api/api/lib/common_setting/resp_format.py index 1d3b8d9..6561d65 100644 --- a/acl-api/api/lib/common_setting/resp_format.py +++ b/acl-api/api/lib/common_setting/resp_format.py @@ -1,56 +1,84 @@ # -*- coding:utf-8 -*- +from flask_babel import lazy_gettext as _l from api.lib.resp_format import CommonErrFormat class ErrFormat(CommonErrFormat): - company_info_is_already_existed = "公司信息已存在!无法创建" - - no_file_part = "没有文件部分" - file_is_required = "文件是必须的" - - direct_supervisor_is_not_self = "直属上级不能是自己" - parent_department_is_not_self = "上级部门不能是自己" - employee_list_is_empty = "员工列表为空" - - column_name_not_support = "不支持的列名" - password_is_required = "密码不能为空" - employee_acl_rid_is_zero = "员工ACL角色ID不能为0" - - generate_excel_failed = "生成excel失败: {}" - rename_columns_failed = "字段转换为中文失败: {}" - cannot_block_this_employee_is_other_direct_supervisor = "该员工是其他员工的直属上级, 不能禁用" - cannot_block_this_employee_is_department_manager = "该员工是部门负责人, 不能禁用" - employee_id_not_found = "员工ID [{}] 不存在" - value_is_required = "值是必须的" - email_already_exists = "邮箱 [{}] 已存在" - query_column_none_keep_value_empty = "查询 {} 空值时请保持value为空" - not_support_operator = "不支持的操作符: {}" - not_support_relation = "不支持的关系: {}" - conditions_field_missing = "conditions内元素字段缺失,请检查!" - datetime_format_error = "{} 格式错误,应该为:%Y-%m-%d %H:%M:%S" - department_level_relation_error = "部门层级关系不正确" - delete_reserved_department_name = "保留部门,无法删除!" - department_id_is_required = "部门ID是必须的" - department_list_is_required = "部门列表是必须的" - cannot_to_be_parent_department = "{} 不能设置为上级部门" - department_id_not_found = "部门ID [{}] 不存在" - parent_department_id_must_more_than_zero = "上级部门ID必须大于0" - department_name_already_exists = "部门名称 [{}] 已存在" - new_department_is_none = "新部门是空的" - - acl_edit_user_failed = "ACL 修改用户失败: {}" - acl_uid_not_found = "ACL 用户UID [{}] 不存在" - acl_add_user_failed = "ACL 添加用户失败: {}" - acl_add_role_failed = "ACL 添加角色失败: {}" - acl_update_role_failed = "ACL 更新角色失败: {}" - acl_get_all_users_failed = "ACL 获取所有用户失败: {}" - acl_remove_user_from_role_failed = "ACL 从角色中移除用户失败: {}" - acl_add_user_to_role_failed = "ACL 添加用户到角色失败: {}" - acl_import_user_failed = "ACL 导入用户[{}]失败: {}" - - nickname_is_required = "用户名不能为空" - username_is_required = "username不能为空" - email_is_required = "邮箱不能为空" - email_format_error = "邮箱格式错误" + company_info_is_already_existed = _l("Company info already existed") # 公司信息已存在!无法创建 + no_file_part = _l("No file part") # 没有文件部分 + file_is_required = _l("File is required") # 文件是必须的 + file_not_found = _l("File not found") # 文件不存在 + file_type_not_allowed = _l("File type not allowed") # 文件类型不允许 + upload_failed = _l("Upload failed: {}") # 上传失败: {} + + direct_supervisor_is_not_self = _l("Direct supervisor is not self") # 直属上级不能是自己 + parent_department_is_not_self = _l("Parent department is not self") # 上级部门不能是自己 + employee_list_is_empty = _l("Employee list is empty") # 员工列表为空 + + column_name_not_support = _l("Column name not support") # 不支持的列名 + password_is_required = _l("Password is required") # 密码是必须的 + employee_acl_rid_is_zero = _l("Employee acl rid is zero") # 员工ACL角色ID不能为0 + + generate_excel_failed = _l("Generate excel failed: {}") # 生成excel失败: {} + rename_columns_failed = _l("Rename columns failed: {}") # 重命名字段失败: {} + cannot_block_this_employee_is_other_direct_supervisor = _l( + "Cannot block this employee is other direct supervisor") # 该员工是其他员工的直属上级, 不能禁用 + cannot_block_this_employee_is_department_manager = _l( + "Cannot block this employee is department manager") # 该员工是部门负责人, 不能禁用 + employee_id_not_found = _l("Employee id [{}] not found") # 员工ID [{}] 不存在 + value_is_required = _l("Value is required") # 值是必须的 + email_already_exists = _l("Email already exists") # 邮箱已存在 + query_column_none_keep_value_empty = _l("Query {} none keep value empty") # 查询 {} 空值时请保持value为空" + not_support_operator = _l("Not support operator: {}") # 不支持的操作符: {} + not_support_relation = _l("Not support relation: {}") # 不支持的关系: {} + conditions_field_missing = _l("Conditions field missing") # conditions内元素字段缺失,请检查! + datetime_format_error = _l("Datetime format error: {}") # {} 格式错误,应该为:%Y-%m-%d %H:%M:%S + department_level_relation_error = _l("Department level relation error") # 部门层级关系不正确 + delete_reserved_department_name = _l("Delete reserved department name") # 保留部门,无法删除! + department_id_is_required = _l("Department id is required") # 部门ID是必须的 + department_list_is_required = _l("Department list is required") # 部门列表是必须的 + cannot_to_be_parent_department = _l("{} Cannot to be parent department") # 不能设置为上级部门 + department_id_not_found = _l("Department id [{}] not found") # 部门ID [{}] 不存在 + parent_department_id_must_more_than_zero = _l("Parent department id must more than zero") # 上级部门ID必须大于0 + department_name_already_exists = _l("Department name [{}] already exists") # 部门名称 [{}] 已存在 + new_department_is_none = _l("New department is none") # 新部门是空的 + + acl_edit_user_failed = _l("ACL edit user failed: {}") # ACL 修改用户失败: {} + acl_uid_not_found = _l("ACL uid not found: {}") # ACL 用户UID [{}] 不存在 + acl_add_user_failed = _l("ACL add user failed: {}") # ACL 添加用户失败: {} + acl_add_role_failed = _l("ACL add role failed: {}") # ACL 添加角色失败: {} + acl_update_role_failed = _l("ACL update role failed: {}") # ACL 更新角色失败: {} + acl_get_all_users_failed = _l("ACL get all users failed: {}") # ACL 获取所有用户失败: {} + acl_remove_user_from_role_failed = _l("ACL remove user from role failed: {}") # ACL 从角色中移除用户失败: {} + acl_add_user_to_role_failed = _l("ACL add user to role failed: {}") # ACL 添加用户到角色失败: {} + acl_import_user_failed = _l("ACL import user failed: {}") # ACL 导入用户失败: {} + + nickname_is_required = _l("Nickname is required") # 昵称不能为空 + username_is_required = _l("Username is required") # 用户名不能为空 + email_is_required = _l("Email is required") # 邮箱不能为空 + email_format_error = _l("Email format error") # 邮箱格式错误 + email_send_timeout = _l("Email send timeout") # 邮件发送超时 + + common_data_not_found = _l("Common data not found {} ") # ID {} 找不到记录 + common_data_already_existed = _l("Common data {} already existed") # {} 已存在 + notice_platform_existed = _l("Notice platform {} existed") # {} 已存在 + notice_not_existed = _l("Notice {} not existed") # {} 配置项不存在 + notice_please_config_messenger_first = _l("Notice please config messenger first") # 请先配置messenger URL + notice_bind_err_with_empty_mobile = _l("Notice bind err with empty mobile") # 绑定错误,手机号为空 + notice_bind_failed = _l("Notice bind failed: {}") # 绑定失败: {} + notice_bind_success = _l("Notice bind success") # 绑定成功 + notice_remove_bind_success = _l("Notice remove bind success") # 解绑成功 + + not_support_test = _l("Not support test type: {}") # 不支持的测试类型: {} + not_support_auth_type = _l("Not support auth type: {}") # 不支持的认证类型: {} + ldap_server_connect_timeout = _l("LDAP server connect timeout") # LDAP服务器连接超时 + ldap_server_connect_not_available = _l("LDAP server connect not available") # LDAP服务器连接不可用 + ldap_test_unknown_error = _l("LDAP test unknown error: {}") # LDAP测试未知错误: {} + common_data_not_support_auth_type = _l("Common data not support auth type: {}") # 通用数据不支持auth类型: {} + ldap_test_username_required = _l("LDAP test username required") # LDAP测试用户名必填 + + company_wide = _l("Company wide") # 全公司 + + resource_no_permission = _l("No permission to access resource {}, perm {} ") # 没有权限访问 {} 资源的 {} 权限" diff --git a/acl-api/api/lib/common_setting/role_perm_base.py b/acl-api/api/lib/common_setting/role_perm_base.py new file mode 100644 index 0000000..c4fe48f --- /dev/null +++ b/acl-api/api/lib/common_setting/role_perm_base.py @@ -0,0 +1,64 @@ +class OperationPermission(object): + + def __init__(self, resource_perms): + for _r in resource_perms: + setattr(self, _r['page'], _r['page']) + for _p in _r['perms']: + setattr(self, _p, _p) + + +class BaseApp(object): + resource_type_name = 'OperationPermission' + all_resource_perms = [] + + def __init__(self): + self.admin_name = None + self.roles = [] + self.app_name = 'acl' + self.require_create_resource_type = self.resource_type_name + self.extra_create_resource_type_list = [] + + self.op = None + + @staticmethod + def format_role(role_name, role_type, acl_rid, resource_perms, description=''): + return dict( + role_name=role_name, + role_type=role_type, + acl_rid=acl_rid, + description=description, + resource_perms=resource_perms, + ) + + +class CMDBApp(BaseApp): + all_resource_perms = [ + {"page": "Big_Screen", "page_cn": "大屏", "perms": ["read"]}, + {"page": "Dashboard", "page_cn": "仪表盘", "perms": ["read"]}, + {"page": "Resource_Search", "page_cn": "资源搜索", "perms": ["read"]}, + {"page": "Auto_Discovery_Pool", "page_cn": "自动发现池", "perms": ["read"]}, + {"page": "My_Subscriptions", "page_cn": "我的订阅", "perms": ["read"]}, + {"page": "Bulk_Import", "page_cn": "批量导入", "perms": ["read"]}, + {"page": "Model_Configuration", "page_cn": "模型配置", + "perms": ["read", "create_CIType", "create_CIType_group", "update_CIType_group", + "delete_CIType_group", "download_CIType"]}, + {"page": "Backend_Management", "page_cn": "后台管理", "perms": ["read"]}, + {"page": "Customized_Dashboard", "page_cn": "定制仪表盘", "perms": ["read"]}, + {"page": "Service_Tree_Definition", "page_cn": "服务树定义", "perms": ["read"]}, + {"page": "Model_Relationships", "page_cn": "模型关系", "perms": ["read"]}, + {"page": "Operation_Audit", "page_cn": "操作审计", "perms": ["read"]}, + {"page": "Relationship_Types", "page_cn": "关系类型", "perms": ["read"]}, + {"page": "Auto_Discovery", "page_cn": "自动发现", "perms": ["read", "create_plugin", "update_plugin", "delete_plugin"]}, + {"page": "TopologyView", "page_cn": "拓扑视图", + "perms": ["read", "create_topology_group", "update_topology_group", "delete_topology_group", + "create_topology_view"], + }, + ] + + def __init__(self): + super().__init__() + + self.admin_name = 'cmdb_admin' + self.app_name = 'cmdb' + + self.op = OperationPermission(self.all_resource_perms) diff --git a/acl-api/api/lib/common_setting/upload_file.py b/acl-api/api/lib/common_setting/upload_file.py index d7eca29..f63bfc0 100644 --- a/acl-api/api/lib/common_setting/upload_file.py +++ b/acl-api/api/lib/common_setting/upload_file.py @@ -1,11 +1,18 @@ +import base64 import uuid +import os +from io import BytesIO + +from flask import abort, current_app +import lz4.frame from api.lib.common_setting.utils import get_cur_time_str +from api.models.common_setting import CommonFile +from api.lib.common_setting.resp_format import ErrFormat def allowed_file(filename, allowed_extensions): - return '.' in filename and \ - filename.rsplit('.', 1)[1].lower() in allowed_extensions + return '.' in filename and filename.rsplit('.', 1)[1].lower() in allowed_extensions def generate_new_file_name(name): @@ -13,4 +20,75 @@ def generate_new_file_name(name): prev_name = ''.join(name.split(f".{ext}")[:-1]) uid = str(uuid.uuid4()) cur_str = get_cur_time_str('_') + return f"{prev_name}_{cur_str}_{uid}.{ext}" + + +class CommonFileCRUD: + @staticmethod + def add_file(**kwargs): + return CommonFile.create(**kwargs) + + @staticmethod + def get_file(file_name, to_str=False): + existed = CommonFile.get_by(file_name=file_name, first=True, to_dict=False) + if not existed: + abort(400, ErrFormat.file_not_found) + + uncompressed_data = lz4.frame.decompress(existed.binary) + + return base64.b64encode(uncompressed_data).decode('utf-8') if to_str else BytesIO(uncompressed_data) + + @staticmethod + def sync_file_to_db(): + for p in ['UPLOAD_DIRECTORY_FULL']: + upload_path = current_app.config.get(p, None) + if not upload_path: + continue + for root, dirs, files in os.walk(upload_path): + for file in files: + file_path = os.path.join(root, file) + if not os.path.isfile(file_path): + continue + + existed = CommonFile.get_by(file_name=file, first=True, to_dict=False) + if existed: + continue + with open(file_path, 'rb') as f: + data = f.read() + compressed_data = lz4.frame.compress(data) + try: + CommonFileCRUD.add_file( + origin_name=file, + file_name=file, + binary=compressed_data + ) + + current_app.logger.info(f'sync file {file} to db') + except Exception as e: + current_app.logger.error(f'sync file {file} to db error: {e}') + + def get_file_binary_str(self, file_name): + return self.get_file(file_name, True) + + def save_str_to_file(self, file_name, str_data): + try: + self.get_file(file_name) + current_app.logger.info(f'file {file_name} already exists') + return + except Exception as e: + # file not found + pass + + bytes_data = base64.b64decode(str_data) + compressed_data = lz4.frame.compress(bytes_data) + + try: + self.add_file( + origin_name=file_name, + file_name=file_name, + binary=compressed_data + ) + current_app.logger.info(f'save_str_to_file {file_name} success') + except Exception as e: + current_app.logger.error(f"save_str_to_file error: {e}") diff --git a/acl-api/api/lib/common_setting/utils.py b/acl-api/api/lib/common_setting/utils.py index 138a315..d3db5bb 100644 --- a/acl-api/api/lib/common_setting/utils.py +++ b/acl-api/api/lib/common_setting/utils.py @@ -1,5 +1,10 @@ # -*- coding:utf-8 -*- from datetime import datetime +from flask import current_app +from sqlalchemy import inspect, text +from sqlalchemy.dialects.mysql import ENUM + +from api.extensions import db def get_cur_time_str(split_flag='-'): @@ -23,3 +28,115 @@ def all(cls): if not attr.startswith("_") and not callable(getattr(cls, attr)) } return cls._ALL_ + + +class CheckNewColumn(object): + + def __init__(self): + self.engine = db.get_engine() + self.inspector = inspect(self.engine) + self.table_names = self.inspector.get_table_names() + + @staticmethod + def get_model_by_table_name(_table_name): + registry = getattr(db.Model, 'registry', None) + class_registry = getattr(registry, '_class_registry', None) + for _model in class_registry.values(): + if hasattr(_model, '__tablename__') and _model.__tablename__ == _table_name: + return _model + return None + + def run(self): + for table_name in self.table_names: + self.check_by_table(table_name) + + def check_by_table(self, table_name): + existed_columns = self.inspector.get_columns(table_name) + enum_columns = [] + existed_column_name_list = [] + for c in existed_columns: + if isinstance(c['type'], ENUM): + enum_columns.append(c['name']) + existed_column_name_list.append(c['name']) + + model = self.get_model_by_table_name(table_name) + if model is None: + return + model_columns = getattr(getattr(getattr(model, '__table__'), 'columns'), '_all_columns') + for column in model_columns: + if column.name not in existed_column_name_list: + add_res = self.add_new_column(table_name, column) + if not add_res: + continue + + current_app.logger.info(f"add new column [{column.name}] in table [{table_name}] success.") + + if column.name in enum_columns: + enum_columns.remove(column.name) + + self.add_new_index(table_name, column) + + if len(enum_columns) > 0: + self.check_enum_column(enum_columns, existed_columns, model_columns, table_name) + + def add_new_column(self, target_table_name, new_column): + try: + column_type = new_column.type.compile(self.engine.dialect) + default_value = new_column.default.arg if new_column.default else None + + sql = "ALTER TABLE " + target_table_name + " ADD COLUMN " + f"`{new_column.name}`" + " " + column_type + if new_column.comment: + sql += f" comment '{new_column.comment}'" + + if column_type == 'JSON': + pass + elif default_value: + if column_type.startswith('VAR') or column_type.startswith('Text'): + if default_value is None or len(default_value) == 0: + pass + else: + sql += f" DEFAULT {default_value}" + + sql = text(sql) + db.session.execute(sql) + return True + except Exception as e: + err = f"add_new_column [{new_column.name}] to table [{target_table_name}] err: {e}" + current_app.logger.error(err) + return False + + @staticmethod + def add_new_index(target_table_name, new_column): + try: + if new_column.index: + index_name = f"{target_table_name}_{new_column.name}" + sql = "CREATE INDEX " + f"{index_name}" + " ON " + target_table_name + " (" + new_column.name + ")" + db.session.execute(sql) + current_app.logger.info(f"add new index [{index_name}] in table [{target_table_name}] success.") + + return True + except Exception as e: + err = f"add_new_index [{new_column.name}] to table [{target_table_name}] err: {e}" + current_app.logger.error(err) + return False + + @staticmethod + def check_enum_column(enum_columns, existed_columns, model_columns, table_name): + for column_name in enum_columns: + try: + enum_column = list(filter(lambda x: x['name'] == column_name, existed_columns))[0] + old_enum_value = enum_column.get('type', {}).enums + target_column = list(filter(lambda x: x.name == column_name, model_columns))[0] + new_enum_value = target_column.type.enums + + if set(old_enum_value) == set(new_enum_value): + continue + + enum_values_str = ','.join(["'{}'".format(value) for value in new_enum_value]) + sql = f"ALTER TABLE {table_name} MODIFY COLUMN" + f"`{column_name}`" + f" enum({enum_values_str})" + db.session.execute(sql) + current_app.logger.info( + f"modify column [{column_name}] ENUM: {new_enum_value} in table [{table_name}] success.") + except Exception as e: + current_app.logger.error( + f"modify column ENUM [{column_name}] in table [{table_name}] err: {e}") diff --git a/acl-api/api/lib/database.py b/acl-api/api/lib/database.py index 5145bd4..3bb7aa8 100644 --- a/acl-api/api/lib/database.py +++ b/acl-api/api/lib/database.py @@ -10,17 +10,21 @@ class FormatMixin(object): def to_dict(self): - res = dict([(k, getattr(self, k) if not isinstance( - getattr(self, k), (datetime.datetime, datetime.date, datetime.time)) else str( - getattr(self, k))) for k in getattr(self, "__mapper__").c.keys()]) - # FIXME: getattr(cls, "__table__").columns k.name + res = dict() + for k in getattr(self, "__mapper__").c.keys(): + if k in {'password', '_password', 'secret', '_secret'}: + continue - res.pop('password', None) - res.pop('_password', None) - res.pop('secret', None) + if k.startswith('_'): + k = k[1:] + + if not isinstance(getattr(self, k), (datetime.datetime, datetime.date, datetime.time)): + res[k] = getattr(self, k) + else: + res[k] = str(getattr(self, k)) return res - + @classmethod def from_dict(cls, **kwargs): from sqlalchemy.sql.sqltypes import Time, Date, DateTime @@ -80,17 +84,17 @@ def delete(self, flush=False, commit=True): db.session.rollback() raise CommitException(str(e)) - def soft_delete(self, flush=False): + def soft_delete(self, flush=False, commit=True): setattr(self, "deleted", True) setattr(self, "deleted_at", datetime.datetime.now()) - self.save(flush=flush) + self.save(flush=flush, commit=commit) @classmethod def get_by_id(cls, _id): if any((isinstance(_id, six.string_types) and _id.isdigit(), isinstance(_id, (six.integer_types, float))), ): obj = getattr(cls, "query").get(int(_id)) - if obj and not obj.deleted: + if obj and not getattr(obj, 'deleted', False): return obj @classmethod @@ -138,8 +142,11 @@ def get_by(cls, first=False, return result[0] if first and result else (None if first else result) @classmethod - def get_by_like(cls, to_dict=True, **kwargs): + def get_by_like(cls, to_dict=True, deleted=False, **kwargs): query = db.session.query(cls) + if hasattr(cls, "deleted") and deleted is not None: + query = query.filter(cls.deleted.is_(deleted)) + for k, v in kwargs.items(): query = query.filter(getattr(cls, k).ilike('%{0}%'.format(v))) return [i.to_dict() if to_dict else i for i in query] diff --git a/acl-api/api/lib/decorator.py b/acl-api/api/lib/decorator.py index 94b0ce5..26247e6 100644 --- a/acl-api/api/lib/decorator.py +++ b/acl-api/api/lib/decorator.py @@ -4,8 +4,14 @@ from functools import wraps from flask import abort +from flask import current_app from flask import request +from sqlalchemy.exc import InvalidRequestError +from sqlalchemy.exc import OperationalError +from sqlalchemy.exc import PendingRollbackError +from sqlalchemy.exc import StatementError +from api.extensions import db from api.lib.resp_format import CommonErrFormat @@ -55,8 +61,8 @@ def wrapper(*args, **kwargs): if exclude_args and arg in exclude_args: continue - if attr.type.python_type == str and attr.type.length and \ - len(request.values[arg] or '') > attr.type.length: + if attr.type.python_type == str and attr.type.length and ( + len(request.values[arg] or '') > attr.type.length): return abort(400, CommonErrFormat.argument_str_length_limit.format(arg, attr.type.length)) elif attr.type.python_type in (int, float) and request.values[arg]: @@ -70,3 +76,43 @@ def wrapper(*args, **kwargs): return wrapper return decorate + + +def reconnect_db(func): + @wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except (StatementError, OperationalError, InvalidRequestError) as e: + error_msg = str(e) + if 'Lost connection' in error_msg or 'reconnect until invalid transaction' in error_msg or \ + 'can be emitted within this transaction' in error_msg: + current_app.logger.info('[reconnect_db] lost connect rollback then retry') + db.session.rollback() + return func(*args, **kwargs) + else: + raise e + except Exception as e: + raise e + + return wrapper + + +def _flush_db(): + try: + db.session.commit() + except (StatementError, OperationalError, InvalidRequestError, PendingRollbackError): + db.session.rollback() + + +def flush_db(func): + @wraps(func) + def wrapper(*args, **kwargs): + _flush_db() + return func(*args, **kwargs) + + return wrapper + + +def run_flush_db(): + _flush_db() diff --git a/acl-api/api/lib/perm/acl/acl.py b/acl-api/api/lib/perm/acl/acl.py index 74f3011..5af60d5 100644 --- a/acl-api/api/lib/perm/acl/acl.py +++ b/acl-api/api/lib/perm/acl/acl.py @@ -5,8 +5,10 @@ import requests import six -from flask import abort, session -from flask import current_app, request +from flask import abort +from flask import current_app +from flask import request +from flask import session from flask_login import current_user from api.extensions import cache @@ -85,8 +87,8 @@ def _get_role(self, name): if user: return Role.get_by(name=name, uid=user.uid, first=True, to_dict=False) - return Role.get_by(name=name, app_id=self.app_id, first=True, to_dict=False) or \ - Role.get_by(name=name, first=True, to_dict=False) + return (Role.get_by(name=name, app_id=self.app_id, first=True, to_dict=False) or + Role.get_by(name=name, first=True, to_dict=False)) def add_resource(self, name, resource_type_name=None): resource_type = ResourceType.get_by(name=resource_type_name, first=True, to_dict=False) @@ -115,15 +117,15 @@ def grant_resource_to_role(self, name, role, resource_type_name=None, permission if group: PermissionCRUD.grant(role.id, permissions, group_id=group.id) - def grant_resource_to_role_by_rid(self, name, rid, resource_type_name=None, permissions=None): + def grant_resource_to_role_by_rid(self, name, rid, resource_type_name=None, permissions=None, rebuild=True): resource = self._get_resource(name, resource_type_name) if resource: - PermissionCRUD.grant(rid, permissions, resource_id=resource.id) + PermissionCRUD.grant(rid, permissions, resource_id=resource.id, rebuild=rebuild) else: group = self._get_resource_group(name) if group: - PermissionCRUD.grant(rid, permissions, group_id=group.id) + PermissionCRUD.grant(rid, permissions, group_id=group.id, rebuild=rebuild) def revoke_resource_from_role(self, name, role, resource_type_name=None, permissions=None): resource = self._get_resource(name, resource_type_name) @@ -136,26 +138,26 @@ def revoke_resource_from_role(self, name, role, resource_type_name=None, permiss if group: PermissionCRUD.revoke(role.id, permissions, group_id=group.id) - def revoke_resource_from_role_by_rid(self, name, rid, resource_type_name=None, permissions=None): + def revoke_resource_from_role_by_rid(self, name, rid, resource_type_name=None, permissions=None, rebuild=True): resource = self._get_resource(name, resource_type_name) if resource: - PermissionCRUD.revoke(rid, permissions, resource_id=resource.id) + PermissionCRUD.revoke(rid, permissions, resource_id=resource.id, rebuild=rebuild) else: group = self._get_resource_group(name) if group: - PermissionCRUD.revoke(rid, permissions, group_id=group.id) + PermissionCRUD.revoke(rid, permissions, group_id=group.id, rebuild=rebuild) - def del_resource(self, name, resource_type_name=None): + def del_resource(self, name, resource_type_name=None, rebuild=True): resource = self._get_resource(name, resource_type_name) if resource: - ResourceCRUD.delete(resource.id) + return ResourceCRUD.delete(resource.id, rebuild=rebuild) - def has_permission(self, resource_name, resource_type, perm, resource_id=None): + def has_permission(self, resource_name, resource_type, perm, resource_id=None, rid=None): if is_app_admin(self.app_id): return True - role = self._get_role(current_user.username) + role = self._get_role(current_user.username) if rid is None else RoleCache.get(rid) role or abort(404, ErrFormat.role_not_found.format(current_user.username)) diff --git a/acl-api/api/lib/perm/acl/app.py b/acl-api/api/lib/perm/acl/app.py index 93772bc..cf36432 100644 --- a/acl-api/api/lib/perm/acl/app.py +++ b/acl-api/api/lib/perm/acl/app.py @@ -8,7 +8,9 @@ from flask import current_app from api.extensions import db -from api.lib.perm.acl.audit import AuditCRUD, AuditOperateType, AuditScope +from api.lib.perm.acl.audit import AuditCRUD +from api.lib.perm.acl.audit import AuditOperateType +from api.lib.perm.acl.audit import AuditScope from api.lib.perm.acl.resp_format import ErrFormat from api.models.acl import App diff --git a/acl-api/api/lib/perm/acl/audit.py b/acl-api/api/lib/perm/acl/audit.py index 921d619..dfa44be 100644 --- a/acl-api/api/lib/perm/acl/audit.py +++ b/acl-api/api/lib/perm/acl/audit.py @@ -1,16 +1,29 @@ # -*- coding:utf-8 -*- + +import datetime import itertools import json from enum import Enum from typing import List -from flask import g, has_request_context, request +from flask import has_request_context +from flask import request from flask_login import current_user from sqlalchemy import func +from api.extensions import db from api.lib.perm.acl import AppCache -from api.models.acl import AuditPermissionLog, AuditResourceLog, AuditRoleLog, AuditTriggerLog, Permission, Resource, \ - ResourceGroup, ResourceType, Role, RolePermission +from api.models.acl import AuditLoginLog +from api.models.acl import AuditPermissionLog +from api.models.acl import AuditResourceLog +from api.models.acl import AuditRoleLog +from api.models.acl import AuditTriggerLog +from api.models.acl import Permission +from api.models.acl import Resource +from api.models.acl import ResourceGroup +from api.models.acl import ResourceType +from api.models.acl import Role +from api.models.acl import RolePermission class AuditScope(str, Enum): @@ -49,9 +62,7 @@ class AuditCRUD(object): @staticmethod def get_current_operate_uid(uid=None): - - user_id = uid or (hasattr(g, 'user') and getattr(current_user, 'uid', None)) \ - or getattr(current_user, 'user_id', None) + user_id = uid or (getattr(current_user, 'uid', None)) or getattr(current_user, 'user_id', None) if has_request_context() and request.headers.get('X-User-Id'): _user_id = request.headers['X-User-Id'] @@ -93,11 +104,8 @@ def search_permission(app_id, q=None, page=1, page_size=10, start=None, end=None criterion.append(AuditPermissionLog.operate_type == v) records = AuditPermissionLog.query.filter( - AuditPermissionLog.deleted == 0, - *criterion) \ - .order_by(AuditPermissionLog.id.desc()) \ - .offset((page - 1) * page_size) \ - .limit(page_size).all() + AuditPermissionLog.deleted == 0, *criterion).order_by( + AuditPermissionLog.id.desc()).offset((page - 1) * page_size).limit(page_size).all() data = { 'data': [r.to_dict() for r in records], @@ -160,10 +168,8 @@ def search_role(app_id, q=None, page=1, page_size=10, start=None, end=None): elif k == 'operate_type': criterion.append(AuditRoleLog.operate_type == v) - records = AuditRoleLog.query.filter(AuditRoleLog.deleted == 0, *criterion) \ - .order_by(AuditRoleLog.id.desc()) \ - .offset((page - 1) * page_size) \ - .limit(page_size).all() + records = AuditRoleLog.query.filter(AuditRoleLog.deleted == 0, *criterion).order_by( + AuditRoleLog.id.desc()).offset((page - 1) * page_size).limit(page_size).all() data = { 'data': [r.to_dict() for r in records], @@ -225,11 +231,8 @@ def search_resource(app_id, q=None, page=1, page_size=10, start=None, end=None): criterion.append(AuditResourceLog.operate_type == v) records = AuditResourceLog.query.filter( - AuditResourceLog.deleted == 0, - *criterion) \ - .order_by(AuditResourceLog.id.desc()) \ - .offset((page - 1) * page_size) \ - .limit(page_size).all() + AuditResourceLog.deleted == 0, *criterion).order_by( + AuditResourceLog.id.desc()).offset((page - 1) * page_size).limit(page_size).all() data = { 'data': [r.to_dict() for r in records], @@ -259,11 +262,8 @@ def search_trigger(app_id, q=None, page=1, page_size=10, start=None, end=None): criterion.append(AuditTriggerLog.operate_type == v) records = AuditTriggerLog.query.filter( - AuditTriggerLog.deleted == 0, - *criterion) \ - .order_by(AuditTriggerLog.id.desc()) \ - .offset((page - 1) * page_size) \ - .limit(page_size).all() + AuditTriggerLog.deleted == 0, *criterion).order_by( + AuditTriggerLog.id.desc()).offset((page - 1) * page_size).limit(page_size).all() data = { 'data': [r.to_dict() for r in records], @@ -288,6 +288,27 @@ def search_trigger(app_id, q=None, page=1, page_size=10, start=None, end=None): return data + @staticmethod + def search_login(_, q=None, page=1, page_size=10, start=None, end=None): + query = db.session.query(AuditLoginLog) + + if start: + query = query.filter(AuditLoginLog.login_at >= start) + if end: + query = query.filter(AuditLoginLog.login_at <= end) + + if q: + query = query.filter(AuditLoginLog.username == q) + + records = query.order_by( + AuditLoginLog.id.desc()).offset((page - 1) * page_size).limit(page_size).all() + + data = { + 'data': [r.to_dict() for r in records], + } + + return data + @classmethod def add_role_log(cls, app_id, operate_type: AuditOperateType, scope: AuditScope, link_id: int, origin: dict, current: dict, extra: dict, @@ -353,3 +374,32 @@ def add_trigger_log(cls, app_id, trigger_id, operate_type: AuditOperateType, AuditTriggerLog.create(app_id=app_id, trigger_id=trigger_id, operate_uid=user_id, operate_type=operate_type.value, origin=origin, current=current, extra=extra, source=source.value) + + @classmethod + def add_login_log(cls, username, is_ok, description, _id=None, logout_at=None, ip=None, browser=None): + if _id is not None: + existed = AuditLoginLog.get_by_id(_id) + if existed is not None: + existed.update(logout_at=logout_at) + return + + payload = dict(username=username, + is_ok=is_ok, + description=description, + logout_at=logout_at, + ip=(ip or request.headers.get('X-Forwarded-For') or + request.headers.get('X-Real-IP') or request.remote_addr or '').split(',')[0], + browser=browser or request.headers.get('User-Agent'), + channel=request.values.get('channel', 'web'), + ) + + if logout_at is None: + payload['login_at'] = datetime.datetime.now() + + try: + from api.lib.common_setting.employee import EmployeeCRUD + EmployeeCRUD.update_last_login_by_uid(current_user.uid) + except: + pass + + return AuditLoginLog.create(**payload).id diff --git a/acl-api/api/lib/perm/acl/cache.py b/acl-api/api/lib/perm/acl/cache.py index b490759..42acbfb 100644 --- a/acl-api/api/lib/perm/acl/cache.py +++ b/acl-api/api/lib/perm/acl/cache.py @@ -2,10 +2,12 @@ import msgpack +import redis_lock from api.extensions import cache from api.extensions import db -from api.lib.utils import Lock +from api.extensions import rd +from api.lib.decorator import flush_db from api.models.acl import App from api.models.acl import Permission from api.models.acl import Resource @@ -60,15 +62,15 @@ class UserCache(object): @classmethod def get(cls, key): - user = cache.get(cls.PREFIX_ID.format(key)) or \ - cache.get(cls.PREFIX_NAME.format(key)) or \ - cache.get(cls.PREFIX_NICK.format(key)) or \ - cache.get(cls.PREFIX_WXID.format(key)) + user = (cache.get(cls.PREFIX_ID.format(key)) or + cache.get(cls.PREFIX_NAME.format(key)) or + cache.get(cls.PREFIX_NICK.format(key)) or + cache.get(cls.PREFIX_WXID.format(key))) if not user: - user = User.query.get(key) or \ - User.query.get_by_username(key) or \ - User.query.get_by_nickname(key) or \ - User.query.get_by_wxid(key) + user = (User.query.get(key) or + User.query.get_by_username(key) or + User.query.get_by_nickname(key) or + User.query.get_by_wxid(key)) if user: cls.set(user) @@ -136,14 +138,14 @@ def get(cls, app_id): @classmethod def add(cls, rid, app_id): - with Lock('HasResourceRoleCache'): + with redis_lock.Lock(rd.r, 'HasResourceRoleCache'): c = cls.get(app_id) c[rid] = 1 cache.set(cls.PREFIX_KEY.format(app_id), c, timeout=0) @classmethod def remove(cls, rid, app_id): - with Lock('HasResourceRoleCache'): + with redis_lock.Lock(rd.r, 'HasResourceRoleCache'): c = cls.get(app_id) c.pop(rid, None) cache.set(cls.PREFIX_KEY.format(app_id), c, timeout=0) @@ -156,9 +158,10 @@ class RoleRelationCache(object): PREFIX_RESOURCES2 = "RoleRelationResources2::id::{0}::AppId::{1}" @classmethod - def get_parent_ids(cls, rid, app_id): + def get_parent_ids(cls, rid, app_id, force=False): parent_ids = cache.get(cls.PREFIX_PARENT.format(rid, app_id)) - if not parent_ids: + if not parent_ids or force: + db.session.commit() from api.lib.perm.acl.role import RoleRelationCRUD parent_ids = RoleRelationCRUD.get_parent_ids(rid, app_id) cache.set(cls.PREFIX_PARENT.format(rid, app_id), parent_ids, timeout=0) @@ -166,9 +169,10 @@ def get_parent_ids(cls, rid, app_id): return parent_ids @classmethod - def get_child_ids(cls, rid, app_id): + def get_child_ids(cls, rid, app_id, force=False): child_ids = cache.get(cls.PREFIX_CHILDREN.format(rid, app_id)) - if not child_ids: + if not child_ids or force: + db.session.commit() from api.lib.perm.acl.role import RoleRelationCRUD child_ids = RoleRelationCRUD.get_child_ids(rid, app_id) cache.set(cls.PREFIX_CHILDREN.format(rid, app_id), child_ids, timeout=0) @@ -176,14 +180,16 @@ def get_child_ids(cls, rid, app_id): return child_ids @classmethod - def get_resources(cls, rid, app_id): + def get_resources(cls, rid, app_id, force=False): """ :param rid: :param app_id: + :param force: :return: {id2perms: {resource_id: [perm,]}, group2perms: {group_id: [perm, ]}} """ resources = cache.get(cls.PREFIX_RESOURCES.format(rid, app_id)) - if not resources: + if not resources or force: + db.session.commit() from api.lib.perm.acl.role import RoleCRUD resources = RoleCRUD.get_resources(rid, app_id) if resources['id2perms'] or resources['group2perms']: @@ -192,9 +198,10 @@ def get_resources(cls, rid, app_id): return resources or {} @classmethod - def get_resources2(cls, rid, app_id): + def get_resources2(cls, rid, app_id, force=False): r_g = cache.get(cls.PREFIX_RESOURCES2.format(rid, app_id)) - if not r_g: + if not r_g or force: + db.session.commit() res = cls.get_resources(rid, app_id) id2perms = res['id2perms'] group2perms = res['group2perms'] @@ -221,24 +228,30 @@ def get_resources2(cls, rid, app_id): return msgpack.loads(r_g, raw=False) @classmethod + @flush_db def rebuild(cls, rid, app_id): - cls.clean(rid, app_id) - db.session.remove() - - cls.get_parent_ids(rid, app_id) - cls.get_child_ids(rid, app_id) - resources = cls.get_resources(rid, app_id) - if resources.get('id2perms') or resources.get('group2perms'): - HasResourceRoleCache.add(rid, app_id) + if app_id is None: + app_ids = [None] + [i.id for i in App.get_by(to_dict=False)] else: - HasResourceRoleCache.remove(rid, app_id) - cls.get_resources2(rid, app_id) + app_ids = [app_id] + + for _app_id in app_ids: + cls.clean(rid, _app_id) + + cls.get_parent_ids(rid, _app_id, force=True) + cls.get_child_ids(rid, _app_id, force=True) + resources = cls.get_resources(rid, _app_id, force=True) + if resources.get('id2perms') or resources.get('group2perms'): + HasResourceRoleCache.add(rid, _app_id) + else: + HasResourceRoleCache.remove(rid, _app_id) + cls.get_resources2(rid, _app_id, force=True) @classmethod + @flush_db def rebuild2(cls, rid, app_id): cache.delete(cls.PREFIX_RESOURCES2.format(rid, app_id)) - db.session.remove() - cls.get_resources2(rid, app_id) + cls.get_resources2(rid, app_id, force=True) @classmethod def clean(cls, rid, app_id): diff --git a/acl-api/api/lib/perm/acl/permission.py b/acl-api/api/lib/perm/acl/permission.py index f0259cc..ed8367a 100644 --- a/acl-api/api/lib/perm/acl/permission.py +++ b/acl-api/api/lib/perm/acl/permission.py @@ -4,7 +4,9 @@ from flask import abort from api.extensions import db -from api.lib.perm.acl.audit import AuditCRUD, AuditOperateType, AuditOperateSource +from api.lib.perm.acl.audit import AuditCRUD +from api.lib.perm.acl.audit import AuditOperateSource +from api.lib.perm.acl.audit import AuditOperateType from api.lib.perm.acl.cache import PermissionCache from api.lib.perm.acl.cache import RoleCache from api.lib.perm.acl.cache import UserCache @@ -69,7 +71,7 @@ def get_all(resource_id=None, group_id=None, need_users=True): @classmethod def get_all2(cls, resource_name, resource_type_name, app_id): - rt = ResourceType.get_by(name=resource_type_name, first=True, to_dict=False) + rt = ResourceType.get_by(name=resource_type_name, app_id=app_id, first=True, to_dict=False) rt or abort(404, ErrFormat.resource_type_not_found.format(resource_type_name)) r = Resource.get_by(name=resource_name, resource_type_id=rt.id, app_id=app_id, first=True, to_dict=False) @@ -77,7 +79,8 @@ def get_all2(cls, resource_name, resource_type_name, app_id): return r and cls.get_all(r.id) @staticmethod - def grant(rid, perms, resource_id=None, group_id=None, rebuild=True, source=AuditOperateSource.acl): + def grant(rid, perms, resource_id=None, group_id=None, rebuild=True, + source=AuditOperateSource.acl, force_update=False): app_id = None rt_id = None @@ -97,15 +100,30 @@ def grant(rid, perms, resource_id=None, group_id=None, rebuild=True, source=Audi elif group_id is not None: from api.models.acl import ResourceGroup - group = ResourceGroup.get_by_id(group_id) or \ - abort(404, ErrFormat.resource_group_not_found.format("id={}".format(group_id))) + group = ResourceGroup.get_by_id(group_id) or abort( + 404, ErrFormat.resource_group_not_found.format("id={}".format(group_id))) app_id = group.app_id rt_id = group.resource_type_id if not perms: perms = [i.get('name') for i in ResourceTypeCRUD.get_perms(group.resource_type_id)] - _role_permissions = [] + if force_update: + revoke_role_permissions = [] + existed_perms = RolePermission.get_by(rid=rid, + app_id=app_id, + group_id=group_id, + resource_id=resource_id, + to_dict=False) + for role_perm in existed_perms: + perm = PermissionCache.get(role_perm.perm_id, rt_id) + if perm and perm.name not in perms: + role_perm.soft_delete() + revoke_role_permissions.append(role_perm) + + AuditCRUD.add_permission_log(app_id, AuditOperateType.revoke, rid, rt_id, + revoke_role_permissions, source=source) + _role_permissions = [] for _perm in set(perms): perm = PermissionCache.get(_perm, rt_id) if not perm: @@ -206,8 +224,8 @@ def revoke(rid, perms, resource_id=None, group_id=None, rebuild=True, source=Aud if resource_id is not None: from api.models.acl import Resource - resource = Resource.get_by_id(resource_id) or \ - abort(404, ErrFormat.resource_not_found.format("id={}".format(resource_id))) + resource = Resource.get_by_id(resource_id) or abort( + 404, ErrFormat.resource_not_found.format("id={}".format(resource_id))) app_id = resource.app_id rt_id = resource.resource_type_id if not perms: @@ -216,8 +234,8 @@ def revoke(rid, perms, resource_id=None, group_id=None, rebuild=True, source=Aud elif group_id is not None: from api.models.acl import ResourceGroup - group = ResourceGroup.get_by_id(group_id) or \ - abort(404, ErrFormat.resource_group_not_found.format("id={}".format(group_id))) + group = ResourceGroup.get_by_id(group_id) or abort( + 404, ErrFormat.resource_group_not_found.format("id={}".format(group_id))) app_id = group.app_id rt_id = group.resource_type_id @@ -272,12 +290,14 @@ def batch_revoke_by_resource_names(rid, perms, resource_type_id, resource_names, perm2resource.setdefault(_perm, []).append(resource_id) for _perm in perm2resource: perm = PermissionCache.get(_perm, resource_type_id) - existeds = RolePermission.get_by(rid=rid, - app_id=app_id, - perm_id=perm.id, - __func_in___key_resource_id=perm2resource[_perm], - to_dict=False) - for existed in existeds: + if perm is None: + continue + exists = RolePermission.get_by(rid=rid, + app_id=app_id, + perm_id=perm.id, + __func_in___key_resource_id=perm2resource[_perm], + to_dict=False) + for existed in exists: existed.deleted = True existed.deleted_at = datetime.datetime.now() db.session.add(existed) diff --git a/acl-api/api/lib/perm/acl/resource.py b/acl-api/api/lib/perm/acl/resource.py index 3673d20..739d104 100644 --- a/acl-api/api/lib/perm/acl/resource.py +++ b/acl-api/api/lib/perm/acl/resource.py @@ -2,10 +2,11 @@ from flask import abort -from flask import current_app from api.extensions import db -from api.lib.perm.acl.audit import AuditCRUD, AuditOperateType, AuditScope +from api.lib.perm.acl.audit import AuditCRUD +from api.lib.perm.acl.audit import AuditOperateType +from api.lib.perm.acl.audit import AuditScope from api.lib.perm.acl.cache import ResourceCache from api.lib.perm.acl.cache import ResourceGroupCache from api.lib.perm.acl.cache import UserCache @@ -102,8 +103,8 @@ def update(cls, rt_id, **kwargs): @classmethod def delete(cls, rt_id): - rt = ResourceType.get_by_id(rt_id) or \ - abort(404, ErrFormat.resource_type_not_found.format("id={}".format(rt_id))) + rt = ResourceType.get_by_id(rt_id) or abort( + 404, ErrFormat.resource_type_not_found.format("id={}".format(rt_id))) Resource.get_by(resource_type_id=rt_id) and abort(400, ErrFormat.resource_type_cannot_delete) @@ -125,11 +126,18 @@ def update_perms(cls, rt_id, perms, app_id): existed_ids = [i.id for i in existed] current_ids = [] + rebuild_rids = set() for i in existed: if i.name not in perms: - i.soft_delete() + i.soft_delete(commit=False) + for rp in RolePermission.get_by(perm_id=i.id, to_dict=False): + rp.soft_delete(commit=False) + rebuild_rids.add((rp.app_id, rp.rid)) else: current_ids.append(i.id) + db.session.commit() + for _app_id, _rid in rebuild_rids: + role_rebuild.apply_async(args=(_rid, _app_id), queue=ACL_QUEUE) for i in perms: if i not in existed_names: @@ -165,8 +173,8 @@ def get_items(rg_id): @staticmethod def add(name, type_id, app_id, uid=None): - ResourceGroup.get_by(name=name, resource_type_id=type_id, app_id=app_id) and \ - abort(400, ErrFormat.resource_group_exists.format(name)) + ResourceGroup.get_by(name=name, resource_type_id=type_id, app_id=app_id) and abort( + 400, ErrFormat.resource_group_exists.format(name)) rg = ResourceGroup.create(name=name, resource_type_id=type_id, app_id=app_id, uid=uid) AuditCRUD.add_resource_log(app_id, AuditOperateType.create, @@ -175,8 +183,8 @@ def add(name, type_id, app_id, uid=None): @staticmethod def update(rg_id, items): - rg = ResourceGroup.get_by_id(rg_id) or \ - abort(404, ErrFormat.resource_group_not_found.format("id={}".format(rg_id))) + rg = ResourceGroup.get_by_id(rg_id) or abort( + 404, ErrFormat.resource_group_not_found.format("id={}".format(rg_id))) existed = ResourceGroupItems.get_by(group_id=rg_id, to_dict=False) existed_ids = [i.resource_id for i in existed] @@ -196,8 +204,8 @@ def update(rg_id, items): @staticmethod def delete(rg_id): - rg = ResourceGroup.get_by_id(rg_id) or \ - abort(404, ErrFormat.resource_group_not_found.format("id={}".format(rg_id))) + rg = ResourceGroup.get_by_id(rg_id) or abort( + 404, ErrFormat.resource_group_not_found.format("id={}".format(rg_id))) origin = rg.to_dict() rg.soft_delete() @@ -267,14 +275,13 @@ def search(cls, q, u, app_id, resource_type_id=None, page=1, page_size=None): def add(cls, name, type_id, app_id, uid=None): type_id = cls._parse_resource_type_id(type_id, app_id) - Resource.get_by(name=name, resource_type_id=type_id, app_id=app_id) and \ - abort(400, ErrFormat.resource_exists.format(name)) + Resource.get_by(name=name, resource_type_id=type_id, app_id=app_id) and abort( + 400, ErrFormat.resource_exists.format(name)) r = Resource.create(name=name, resource_type_id=type_id, app_id=app_id, uid=uid) from api.tasks.acl import apply_trigger triggers = TriggerCRUD.match_triggers(app_id, r.name, r.resource_type_id, uid) - current_app.logger.info(triggers) for trigger in triggers: # auto trigger should be no uid apply_trigger.apply_async(args=(trigger.id,), @@ -308,9 +315,12 @@ def update(_id, name): return resource @staticmethod - def delete(_id): + def delete(_id, rebuild=True, app_id=None): resource = Resource.get_by_id(_id) or abort(404, ErrFormat.resource_not_found.format("id={}".format(_id))) + if app_id is not None and resource.app_id != app_id: + return abort(404, ErrFormat.resource_not_found.format("id={}".format(_id))) + origin = resource.to_dict() resource.soft_delete() @@ -321,12 +331,15 @@ def delete(_id): i.soft_delete() rebuilds.append((i.rid, i.app_id)) - for rid, app_id in set(rebuilds): - role_rebuild.apply_async(args=(rid, app_id), queue=ACL_QUEUE) + if rebuild: + for rid, app_id in set(rebuilds): + role_rebuild.apply_async(args=(rid, app_id), queue=ACL_QUEUE) AuditCRUD.add_resource_log(resource.app_id, AuditOperateType.delete, AuditScope.resource, resource.id, origin, {}, {}) + return rebuilds + @classmethod def delete_by_name(cls, name, type_id, app_id): resource = Resource.get_by(name=name, resource_type_id=type_id, app_id=app_id) or abort( diff --git a/acl-api/api/lib/perm/acl/resp_format.py b/acl-api/api/lib/perm/acl/resp_format.py index 25f6bdc..8304f87 100644 --- a/acl-api/api/lib/perm/acl/resp_format.py +++ b/acl-api/api/lib/perm/acl/resp_format.py @@ -1,43 +1,50 @@ # -*- coding:utf-8 -*- +from flask_babel import lazy_gettext as _l + from api.lib.resp_format import CommonErrFormat class ErrFormat(CommonErrFormat): - auth_only_with_app_token_failed = "应用 Token验证失败" - session_invalid = "您不是应用管理员 或者 session失效(尝试一下退出重新登录)" - - resource_type_not_found = "资源类型 {} 不存在!" - resource_type_exists = "资源类型 {} 已经存在!" - resource_type_cannot_delete = "因为该类型下有资源的存在, 不能删除!" - - user_not_found = "用户 {} 不存在!" - user_exists = "用户 {} 已经存在!" - role_not_found = "角色 {} 不存在!" - role_exists = "角色 {} 已经存在!" - global_role_not_found = "全局角色 {} 不存在!" - global_role_exists = "全局角色 {} 已经存在!" - user_role_delete_invalid = "删除用户角色, 请在 用户管理 页面操作!" - - resource_no_permission = "您没有资源: {} 的 {} 权限" - admin_required = "需要管理员权限" - role_required = "需要角色: {}" - - app_is_ready_existed = "应用 {} 已经存在" - app_not_found = "应用 {} 不存在!" - app_secret_invalid = "应用的Secret无效" - - resource_not_found = "资源 {} 不存在!" - resource_exists = "资源 {} 已经存在!" - - resource_group_not_found = "资源组 {} 不存在!" - resource_group_exists = "资源组 {} 已经存在!" - - inheritance_dead_loop = "继承检测到了死循环" - role_relation_not_found = "角色关系 {} 不存在!" - - trigger_not_found = "触发器 {} 不存在!" - trigger_exists = "触发器 {} 已经存在!" - trigger_disabled = "触发器 {} 已经被禁用!" - - invalid_password = "密码不正确!" + login_succeed = _l("login successful") # 登录成功 + ldap_connection_failed = _l("Failed to connect to LDAP service") # 连接LDAP服务失败 + invalid_password = _l("Password verification failed") # 密码验证失败 + auth_only_with_app_token_failed = _l("Application Token verification failed") # 应用 Token验证失败 + # 您不是应用管理员 或者 session失效(尝试一下退出重新登录) + session_invalid = _l( + "You are not the application administrator or the session has expired (try logging out and logging in again)") + + resource_type_not_found = _l("Resource type {} does not exist!") # 资源类型 {} 不存在! + resource_type_exists = _l("Resource type {} already exists!") # 资源类型 {} 已经存在! + # 因为该类型下有资源的存在, 不能删除! + resource_type_cannot_delete = _l("Because there are resources under this type, they cannot be deleted!") + + user_not_found = _l("User {} does not exist!") # 用户 {} 不存在! + user_exists = _l("User {} already exists!") # 用户 {} 已经存在! + role_not_found = _l("Role {} does not exist!") # 角色 {} 不存在! + role_exists = _l("Role {} already exists!") # 角色 {} 已经存在! + global_role_not_found = _l("Global role {} does not exist!") # 全局角色 {} 不存在! + global_role_exists = _l("Global role {} already exists!") # 全局角色 {} 已经存在! + + resource_no_permission = _l("You do not have {} permission on resource: {}") # 您没有资源: {} 的 {} 权限 + admin_required = _l("Requires administrator permissions") # 需要管理员权限 + role_required = _l("Requires role: {}") # 需要角色: {} + # 删除用户角色, 请在 用户管理 页面操作! + user_role_delete_invalid = _l("To delete a user role, please operate on the User Management page!") + + app_is_ready_existed = _l("Application {} already exists") # 应用 {} 已经存在 + app_not_found = _l("Application {} does not exist!") # 应用 {} 不存在! + app_secret_invalid = _l("The Secret is invalid") # 应用的Secret无效 + + resource_not_found = _l("Resource {} does not exist!") # 资源 {} 不存在! + resource_exists = _l("Resource {} already exists!") # 资源 {} 已经存在! + + resource_group_not_found = _l("Resource group {} does not exist!") # 资源组 {} 不存在! + resource_group_exists = _l("Resource group {} already exists!") # 资源组 {} 已经存在! + + inheritance_dead_loop = _l("Inheritance detected infinite loop") # 继承检测到了死循环 + role_relation_not_found = _l("Role relationship {} does not exist!") # 角色关系 {} 不存在! + + trigger_not_found = _l("Trigger {} does not exist!") # 触发器 {} 不存在! + trigger_exists = _l("Trigger {} already exists!") # 触发器 {} 已经存在! + trigger_disabled = _l("Trigger {} has been disabled!") # Trigger {} has been disabled! diff --git a/acl-api/api/lib/perm/acl/role.py b/acl-api/api/lib/perm/acl/role.py index 0c2a28c..b9fbe63 100644 --- a/acl-api/api/lib/perm/acl/role.py +++ b/acl-api/api/lib/perm/acl/role.py @@ -1,14 +1,14 @@ # -*- coding:utf-8 -*- -import time - +import redis_lock import six from flask import abort from flask import current_app from sqlalchemy import or_ from api.extensions import db +from api.extensions import rd from api.lib.perm.acl.app import AppCRUD from api.lib.perm.acl.audit import AuditCRUD, AuditOperateType, AuditScope from api.lib.perm.acl.cache import AppCache @@ -62,7 +62,9 @@ def get_parents(rids=None, uids=None, app_id=None, all_app=False): id2parents = {} for i in res: - id2parents.setdefault(rid2uid.get(i.child_id, i.child_id), []).append(RoleCache.get(i.parent_id).to_dict()) + parent = RoleCache.get(i.parent_id) + if parent: + id2parents.setdefault(rid2uid.get(i.child_id, i.child_id), []).append(parent.to_dict()) return id2parents @@ -70,7 +72,7 @@ def get_parents(rids=None, uids=None, app_id=None, all_app=False): def get_parent_ids(rid, app_id): if app_id is not None: return [i.parent_id for i in RoleRelation.get_by(child_id=rid, app_id=app_id, to_dict=False)] + \ - [i.parent_id for i in RoleRelation.get_by(child_id=rid, app_id=None, to_dict=False)] + [i.parent_id for i in RoleRelation.get_by(child_id=rid, app_id=None, to_dict=False)] else: return [i.parent_id for i in RoleRelation.get_by(child_id=rid, app_id=app_id, to_dict=False)] @@ -78,7 +80,7 @@ def get_parent_ids(rid, app_id): def get_child_ids(rid, app_id): if app_id is not None: return [i.child_id for i in RoleRelation.get_by(parent_id=rid, app_id=app_id, to_dict=False)] + \ - [i.child_id for i in RoleRelation.get_by(parent_id=rid, app_id=None, to_dict=False)] + [i.child_id for i in RoleRelation.get_by(parent_id=rid, app_id=None, to_dict=False)] else: return [i.child_id for i in RoleRelation.get_by(parent_id=rid, app_id=app_id, to_dict=False)] @@ -141,24 +143,27 @@ def get_users_by_rid(cls, rid, app_id, rid2obj=None, uid2obj=None): @classmethod def add(cls, role, parent_id, child_ids, app_id): - result = [] - for child_id in child_ids: - existed = RoleRelation.get_by(parent_id=parent_id, child_id=child_id, app_id=app_id) - if existed: - continue + with redis_lock.Lock(rd.r, "ROLE_RELATION_ADD"): + db.session.commit() + + result = [] + for child_id in child_ids: + existed = RoleRelation.get_by(parent_id=parent_id, child_id=child_id, app_id=app_id) + if existed: + continue - RoleRelationCache.clean(parent_id, app_id) - RoleRelationCache.clean(child_id, app_id) + if parent_id in cls.recursive_child_ids(child_id, app_id): + return abort(400, ErrFormat.inheritance_dead_loop) - if parent_id in cls.recursive_child_ids(child_id, app_id): - return abort(400, ErrFormat.inheritance_dead_loop) + result.append(RoleRelation.create(parent_id=parent_id, child_id=child_id, app_id=app_id).to_dict()) - if app_id is None: - for app in AppCRUD.get_all(): - if app.name != "acl": - RoleRelationCache.clean(child_id, app.id) + RoleRelationCache.clean(parent_id, app_id) + RoleRelationCache.clean(child_id, app_id) - result.append(RoleRelation.create(parent_id=parent_id, child_id=child_id, app_id=app_id).to_dict()) + if app_id is None: + for app in AppCRUD.get_all(): + if app.name != "acl": + RoleRelationCache.clean(child_id, app.id) AuditCRUD.add_role_log(app_id, AuditOperateType.role_relation_add, AuditScope.role_relation, role.id, {}, {}, @@ -372,16 +377,16 @@ def _merge(a, b): resource_type_id = resource_type and resource_type.id result = dict(resources=dict(), groups=dict()) - s = time.time() + # s = time.time() parent_ids = RoleRelationCRUD.recursive_parent_ids(rid, app_id) - current_app.logger.info('parent ids {0}: {1}'.format(parent_ids, time.time() - s)) + # current_app.logger.info('parent ids {0}: {1}'.format(parent_ids, time.time() - s)) for parent_id in parent_ids: _resources, _groups = cls._extend_resources(parent_id, resource_type_id, app_id) - current_app.logger.info('middle1: {0}'.format(time.time() - s)) + # current_app.logger.info('middle1: {0}'.format(time.time() - s)) _merge(result['resources'], _resources) - current_app.logger.info('middle2: {0}'.format(time.time() - s)) - current_app.logger.info(len(_groups)) + # current_app.logger.info('middle2: {0}'.format(time.time() - s)) + # current_app.logger.info(len(_groups)) if not group_flat: _merge(result['groups'], _groups) else: @@ -392,7 +397,7 @@ def _merge(a, b): item.setdefault('permissions', []) item['permissions'] = list(set(item['permissions'] + _groups[rg_id]['permissions'])) result['resources'][item['id']] = item - current_app.logger.info('End: {0}'.format(time.time() - s)) + # current_app.logger.info('End: {0}'.format(time.time() - s)) result['resources'] = list(result['resources'].values()) result['groups'] = list(result['groups'].values()) diff --git a/acl-api/api/lib/perm/acl/trigger.py b/acl-api/api/lib/perm/acl/trigger.py index f5a5d3f..8035e4f 100644 --- a/acl-api/api/lib/perm/acl/trigger.py +++ b/acl-api/api/lib/perm/acl/trigger.py @@ -6,9 +6,10 @@ import re from fnmatch import fnmatch -from flask import abort, current_app +from flask import abort -from api.lib.perm.acl.audit import AuditCRUD, AuditOperateType +from api.lib.perm.acl.audit import AuditCRUD +from api.lib.perm.acl.audit import AuditOperateType from api.lib.perm.acl.cache import UserCache from api.lib.perm.acl.const import ACL_QUEUE from api.lib.perm.acl.resp_format import ErrFormat diff --git a/acl-api/api/lib/perm/acl/user.py b/acl-api/api/lib/perm/acl/user.py index bd6fb18..d17af58 100644 --- a/acl-api/api/lib/perm/acl/user.py +++ b/acl-api/api/lib/perm/acl/user.py @@ -9,7 +9,9 @@ from flask_login import current_user from api.extensions import db -from api.lib.perm.acl.audit import AuditCRUD, AuditOperateType, AuditScope +from api.lib.perm.acl.audit import AuditCRUD +from api.lib.perm.acl.audit import AuditOperateType +from api.lib.perm.acl.audit import AuditScope from api.lib.perm.acl.cache import UserCache from api.lib.perm.acl.resp_format import ErrFormat from api.lib.perm.acl.role import RoleCRUD @@ -39,6 +41,7 @@ def gen_key_secret(): @classmethod def add(cls, **kwargs): + add_from = kwargs.pop('add_from', None) existed = User.get_by(username=kwargs['username']) existed and abort(400, ErrFormat.user_exists.format(kwargs['username'])) @@ -49,20 +52,24 @@ def add(cls, **kwargs): kwargs['block'] = 0 kwargs['key'], kwargs['secret'] = cls.gen_key_secret() - user_employee = db.session.query(User).filter(User.deleted.is_(False)).order_by( - User.employee_id.desc()).first() + user_employee = db.session.query(User).filter(User.deleted.is_(False)).order_by(User.employee_id.desc()).first() - biggest_employee_id = int(float(user_employee.employee_id)) \ - if user_employee is not None else 0 + biggest_employee_id = int(float(user_employee.employee_id)) if user_employee is not None else 0 kwargs['employee_id'] = '{0:04d}'.format(biggest_employee_id + 1) user = User.create(**kwargs) - RoleCRUD.add_role(user.username, uid=user.uid) + role = RoleCRUD.add_role(user.username, uid=user.uid) AuditCRUD.add_role_log(None, AuditOperateType.create, AuditScope.user, user.uid, {}, user.to_dict(), {}, {} ) + if add_from != 'common': + from api.lib.common_setting.employee import EmployeeCRUD + payload = {column: getattr(user, column) for column in ['uid', 'username', 'nickname', 'email', 'block']} + payload['rid'] = role.id + EmployeeCRUD.add_employee_from_acl_created(**payload) + return user @staticmethod diff --git a/acl-api/api/lib/perm/auth.py b/acl-api/api/lib/perm/auth.py index 76e2481..f2c66e7 100644 --- a/acl-api/api/lib/perm/auth.py +++ b/acl-api/api/lib/perm/auth.py @@ -51,12 +51,12 @@ def _auth_with_key(): user, authenticated = User.query.authenticate_with_key(key, secret, req_args, path) if user and authenticated: login_user(user) - reset_session(user) + # reset_session(user) return True role, authenticated = Role.query.authenticate_with_key(key, secret, req_args, path) if role and authenticated: - reset_session(None, role=role.name) + # reset_session(None, role=role.name) return True return False @@ -93,6 +93,9 @@ def _auth_with_token(): def _auth_with_ip_white_list(): + if request.url.endswith("acl/users/info"): + return False + ip = request.headers.get('X-Real-IP') or request.remote_addr key = request.values.get('_key') secret = request.values.get('_secret') diff --git a/acl-api/api/lib/perm/authentication/__init__.py b/acl-api/api/lib/perm/authentication/__init__.py new file mode 100644 index 0000000..380474e --- /dev/null +++ b/acl-api/api/lib/perm/authentication/__init__.py @@ -0,0 +1 @@ +# -*- coding:utf-8 -*- diff --git a/acl-api/api/lib/perm/authentication/cas/__init__.py b/acl-api/api/lib/perm/authentication/cas/__init__.py new file mode 100644 index 0000000..5f0fd52 --- /dev/null +++ b/acl-api/api/lib/perm/authentication/cas/__init__.py @@ -0,0 +1,78 @@ +# -*- coding:utf-8 -*- + +""" +flask_cas.__init__ +""" + +import flask +from flask import current_app + +# Find the stack on which we want to store the database connection. +# Starting with Flask 0.9, the _app_ctx_stack is the correct one, +# before that we need to use the _request_ctx_stack. +try: + from flask import _app_ctx_stack as stack +except ImportError: + from flask import _request_ctx_stack as stack + +from . import routing + + +class CAS(object): + """ + Required Configs: + + |Key | + |----------------| + |CAS_SERVER | + |CAS_AFTER_LOGIN | + + Optional Configs: + + |Key | Default | + |-------------------------|----------------| + |CAS_TOKEN_SESSION_KEY | _CAS_TOKEN | + |CAS_USERNAME_SESSION_KEY | CAS_USERNAME | + |CAS_LOGIN_ROUTE | '/cas' | + |CAS_LOGOUT_ROUTE | '/cas/logout' | + |CAS_VALIDATE_ROUTE | '/cas/validate'| + """ + + def __init__(self, app=None, url_prefix=None): + self._app = app + if app is not None: + self.init_app(app, url_prefix) + + def init_app(self, app, url_prefix=None): + # Configuration defaults + app.config.setdefault('CAS_TOKEN_SESSION_KEY', '_CAS_TOKEN') + app.config.setdefault('CAS_USERNAME_SESSION_KEY', 'CAS_USERNAME') + app.config.setdefault('CAS_LOGIN_ROUTE', '/login') + app.config.setdefault('CAS_LOGOUT_ROUTE', '/logout') + app.config.setdefault('CAS_VALIDATE_ROUTE', '/serviceValidate') + # Register Blueprint + app.register_blueprint(routing.blueprint, url_prefix=url_prefix) + + # Use the newstyle teardown_appcontext if it's available, + # otherwise fall back to the request context + if hasattr(app, 'teardown_appcontext'): + app.teardown_appcontext(self.teardown) + else: + app.teardown_request(self.teardown) + + def teardown(self, exception): + ctx = stack.top + + @property + def app(self): + return self._app or current_app + + @property + def username(self): + return flask.session.get( + self.app.config['CAS_USERNAME_SESSION_KEY'], None) + + @property + def token(self): + return flask.session.get( + self.app.config['CAS_TOKEN_SESSION_KEY'], None) \ No newline at end of file diff --git a/acl-api/api/lib/perm/authentication/cas/cas_urls.py b/acl-api/api/lib/perm/authentication/cas/cas_urls.py new file mode 100644 index 0000000..4cbba47 --- /dev/null +++ b/acl-api/api/lib/perm/authentication/cas/cas_urls.py @@ -0,0 +1,122 @@ +# -*- coding:utf-8 -*- + +""" +flask_cas.cas_urls + +Functions for creating urls to access CAS. +""" +from six.moves.urllib.parse import quote +from six.moves.urllib.parse import urlencode +from six.moves.urllib.parse import urljoin + + +def create_url(base, path=None, *query): + """ Create a url. + + Creates a url by combining base, path, and the query's list of + key/value pairs. Escaping is handled automatically. Any + key/value pair with a value that is None is ignored. + + Keyword arguments: + base -- The left most part of the url (ex. http://localhost:5000). + path -- The path after the base (ex. /foo/bar). + query -- A list of key value pairs (ex. [('key', 'value')]). + + Example usage: + >>> create_url( + ... 'http://localhost:5000', + ... 'foo/bar', + ... ('key1', 'value'), + ... ('key2', None), # Will not include None + ... ('url', 'http://example.com'), + ... ) + 'http://localhost:5000/foo/bar?key1=value&url=https%3A%2F%2Fround-lake.dustinice.workers.dev%3A443%2Fhttp%2Fexample.com' + """ + url = base + # Add the path to the url if it's not None. + if path is not None: + url = urljoin(url, quote(path)) + # Remove key/value pairs with None values. + query = filter(lambda pair: pair[1] is not None, query) + # Add the query string to the url + url = urljoin(url, '?{0}'.format(urlencode(list(query)))) + return url + + +def create_cas_login_url(cas_url, cas_route, service, + renew=None, gateway=None): + """ Create a CAS login URL . + + Keyword arguments: + cas_url -- The url to the CAS (ex. http://sso.pdx.edu) + cas_route -- The route where the CAS lives on server (ex. /cas) + service -- (ex. http://localhost:5000/login) + renew -- "true" or "false" + gateway -- "true" or "false" + + Example usage: + >>> create_cas_login_url( + ... 'http://sso.pdx.edu', + ... '/cas', + ... 'http://localhost:5000', + ... ) + 'http://sso.pdx.edu/cas?service=https%3A%2F%2Fround-lake.dustinice.workers.dev%3A443%2Fhttp%2Flocalhost%3A5000' + """ + return create_url( + cas_url, + cas_route, + ('service', service), + ('renew', renew), + ('gateway', gateway), + ) + + +def create_cas_logout_url(cas_url, cas_route, url=None): + """ Create a CAS logout URL. + + Keyword arguments: + cas_url -- The url to the CAS (ex. http://sso.pdx.edu) + cas_route -- The route where the CAS lives on server (ex. /cas/logout) + url -- (ex. http://localhost:5000/login) + + Example usage: + >>> create_cas_logout_url( + ... 'http://sso.pdx.edu', + ... '/cas/logout', + ... 'http://localhost:5000', + ... ) + 'http://sso.pdx.edu/cas/logout?url=https%3A%2F%2Fround-lake.dustinice.workers.dev%3A443%2Fhttp%2Flocalhost%3A5000' + """ + return create_url( + cas_url, + cas_route, + ('service', url), + ) + + +def create_cas_validate_url(cas_url, cas_route, service, ticket, + renew=None): + """ Create a CAS validate URL. + + Keyword arguments: + cas_url -- The url to the CAS (ex. http://sso.pdx.edu) + cas_route -- The route where the CAS lives on server (ex. /cas/validate) + service -- (ex. http://localhost:5000/login) + ticket -- (ex. 'ST-58274-x839euFek492ou832Eena7ee-cas') + renew -- "true" or "false" + + Example usage: + >>> create_cas_validate_url( + ... 'http://sso.pdx.edu', + ... '/cas/validate', + ... 'http://localhost:5000/login', + ... 'ST-58274-x839euFek492ou832Eena7ee-cas' + ... ) + """ + return create_url( + cas_url, + cas_route, + ('service', service), + ('ticket', ticket), + ('renew', renew), + ) diff --git a/acl-api/api/lib/perm/authentication/cas/routing.py b/acl-api/api/lib/perm/authentication/cas/routing.py new file mode 100644 index 0000000..612ad89 --- /dev/null +++ b/acl-api/api/lib/perm/authentication/cas/routing.py @@ -0,0 +1,206 @@ +# -*- coding:utf-8 -*- +import datetime +import uuid + +import bs4 +from flask import Blueprint +from flask import current_app +from flask import redirect +from flask import request +from flask import session +from flask import url_for +from flask_login import login_user +from flask_login import logout_user +from six.moves.urllib.parse import urlparse +from six.moves.urllib_request import urlopen + +from api.lib.common_setting.common_data import AuthenticateDataCRUD +from api.lib.common_setting.const import AuthenticateType +from api.lib.perm.acl.audit import AuditCRUD +from api.lib.perm.acl.cache import UserCache +from api.lib.perm.acl.resp_format import ErrFormat +from .cas_urls import create_cas_login_url +from .cas_urls import create_cas_logout_url +from .cas_urls import create_cas_validate_url + +blueprint = Blueprint('cas', __name__) + + +@blueprint.route('/api/cas/login') +@blueprint.route('/api/sso/login') +def login(): + """ + This route has two purposes. First, it is used by the user + to login. Second, it is used by the CAS to respond with the + `ticket` after the user logs in successfully. + + When the user accesses this url, they are redirected to the CAS + to login. If the login was successful, the CAS will respond to this + route with the ticket in the url. The ticket is then validated. + If validation was successful the logged in username is saved in + the user's session under the key `CAS_USERNAME_SESSION_KEY`. + """ + config = AuthenticateDataCRUD(AuthenticateType.CAS).get() + + cas_token_session_key = current_app.config['CAS_TOKEN_SESSION_KEY'] + if request.values.get("next"): + session["next"] = request.values.get("next") + + # _service = url_for('cas.login', _external=True) + _service = "{}://{}{}".format(urlparse(request.referrer).scheme, + urlparse(request.referrer).netloc, + url_for('cas.login')) + + redirect_url = create_cas_login_url( + config['cas_server'], + config['cas_login_route'], + _service) + + if 'ticket' in request.args: + session[cas_token_session_key] = request.args.get('ticket') + + if request.args.get('ticket'): + + if validate(request.args['ticket']): + redirect_url = session.get("next") or config.get("cas_after_login") or "/" + username = session.get("CAS_USERNAME") + user = UserCache.get(username) + login_user(user) + + session.permanent = True + + _id = AuditCRUD.add_login_log(username, True, ErrFormat.login_succeed) + session['LOGIN_ID'] = _id + + else: + del session[cas_token_session_key] + redirect_url = create_cas_login_url( + config['cas_server'], + config['cas_login_route'], + url_for('cas.login', _external=True), + renew=True) + + AuditCRUD.add_login_log(session.get("CAS_USERNAME"), False, ErrFormat.invalid_password) + + current_app.logger.info("redirect to: {0}".format(redirect_url)) + return redirect(redirect_url) + + +@blueprint.route('/api/cas/logout') +@blueprint.route('/api/sso/logout') +def logout(): + """ + When the user accesses this route they are logged out. + """ + config = AuthenticateDataCRUD(AuthenticateType.CAS).get() + current_app.logger.info(config) + + cas_username_session_key = current_app.config['CAS_USERNAME_SESSION_KEY'] + cas_token_session_key = current_app.config['CAS_TOKEN_SESSION_KEY'] + + cas_username_session_key in session and session.pop(cas_username_session_key) + "acl" in session and session.pop("acl") + "uid" in session and session.pop("uid") + cas_token_session_key in session and session.pop(cas_token_session_key) + "next" in session and session.pop("next") + + redirect_url = create_cas_logout_url( + config['cas_server'], + config['cas_logout_route'], + url_for('cas.login', _external=True, next=request.referrer)) + + logout_user() + + AuditCRUD.add_login_log(None, None, None, _id=session.get('LOGIN_ID'), logout_at=datetime.datetime.now()) + + current_app.logger.debug('Redirecting to: {0}'.format(redirect_url)) + + return redirect(redirect_url) + + +def validate(ticket): + """ + Will attempt to validate the ticket. If validation fails, then False + is returned. If validation is successful, then True is returned + and the validated username is saved in the session under the + key `CAS_USERNAME_SESSION_KEY`. + """ + config = AuthenticateDataCRUD(AuthenticateType.CAS).get() + + cas_username_session_key = current_app.config['CAS_USERNAME_SESSION_KEY'] + + current_app.logger.debug("validating token {0}".format(ticket)) + + cas_validate_url = create_cas_validate_url( + config['cas_validate_server'], + config['cas_validate_route'], + url_for('cas.login', _external=True), + ticket) + + current_app.logger.debug("Making GET request to {0}".format(cas_validate_url)) + + try: + response = urlopen(cas_validate_url).read() + ticket_id = _parse_tag(response, "cas:user") + strs = [s.strip() for s in ticket_id.split('|') if s.strip()] + username, is_valid = None, False + if len(strs) == 1: + username = strs[0] + is_valid = True + except ValueError: + current_app.logger.error("CAS returned unexpected result") + is_valid = False + return is_valid + + if is_valid: + current_app.logger.debug("{}: {}".format(cas_username_session_key, username)) + session[cas_username_session_key] = username + user = UserCache.get(username) + if user is None: + current_app.logger.info("create user: {}".format(username)) + from api.lib.perm.acl.user import UserCRUD + soup = bs4.BeautifulSoup(response) + cas_user_map = config.get('cas_user_map') + user_dict = dict() + for k in cas_user_map: + v = soup.find(cas_user_map[k]['tag'], cas_user_map[k].get('attrs', {})) + user_dict[k] = v and v.text or None + user_dict['password'] = uuid.uuid4().hex + if "email" not in user_dict: + user_dict['email'] = username + + UserCRUD.add(**user_dict) + + from api.lib.perm.acl.acl import ACLManager + user_info = ACLManager.get_user_info(username) + + session["acl"] = dict(uid=user_info.get("uid"), + avatar=user.avatar if user else user_info.get("avatar"), + userId=user_info.get("uid"), + rid=user_info.get("rid"), + userName=user_info.get("username"), + nickName=user_info.get("nickname"), + parentRoles=user_info.get("parents"), + childRoles=user_info.get("children"), + roleName=user_info.get("role")) + session["uid"] = user_info.get("uid") + current_app.logger.debug(session) + current_app.logger.debug(request.url) + else: + current_app.logger.debug("invalid") + + return is_valid + + +def _parse_tag(string, tag): + """ + Used for parsing xml. Search string for the first occurrence of + ..... and return text (stripped of leading and tailing + whitespace) between tags. Return "" if tag not found. + """ + soup = bs4.BeautifulSoup(string) + + if soup.find(tag) is None: + return '' + + return soup.find(tag).string.strip() diff --git a/acl-api/api/lib/perm/authentication/ldap.py b/acl-api/api/lib/perm/authentication/ldap.py new file mode 100644 index 0000000..64e3239 --- /dev/null +++ b/acl-api/api/lib/perm/authentication/ldap.py @@ -0,0 +1,67 @@ +# -*- coding:utf-8 -*- + +import uuid + +from flask import abort +from flask import current_app +from flask import session +from ldap3 import ALL +from ldap3 import AUTO_BIND_NO_TLS +from ldap3 import Connection +from ldap3 import Server +from ldap3.core.exceptions import LDAPBindError +from ldap3.core.exceptions import LDAPCertificateError +from ldap3.core.exceptions import LDAPSocketOpenError + +from api.lib.common_setting.common_data import AuthenticateDataCRUD +from api.lib.common_setting.const import AuthenticateType +from api.lib.perm.acl.audit import AuditCRUD +from api.lib.perm.acl.resp_format import ErrFormat +from api.models.acl import User + + +def authenticate_with_ldap(username, password): + config = AuthenticateDataCRUD(AuthenticateType.LDAP).get() + + server = Server(config.get('ldap_server'), get_info=ALL, connect_timeout=3) + if '@' in username: + email = username + who = config.get('ldap_user_dn').format(username.split('@')[0]) + else: + who = config.get('ldap_user_dn').format(username) + email = "{}@{}".format(who, config.get('ldap_domain')) + + username = username.split('@')[0] + user = User.query.get_by_username(username) + try: + if not password: + raise LDAPCertificateError + + try: + conn = Connection(server, user=who, password=password, auto_bind=AUTO_BIND_NO_TLS) + except LDAPBindError: + conn = Connection(server, + user=f"{username}@{config.get('ldap_domain')}", + password=password, + auto_bind=AUTO_BIND_NO_TLS) + + if conn.result['result'] != 0: + AuditCRUD.add_login_log(username, False, ErrFormat.invalid_password) + raise LDAPBindError + else: + _id = AuditCRUD.add_login_log(username, True, ErrFormat.login_succeed) + session['LOGIN_ID'] = _id + + if not user: + from api.lib.perm.acl.user import UserCRUD + user = UserCRUD.add(username=username, email=email, password=uuid.uuid4().hex) + + return user, True + + except LDAPBindError as e: + current_app.logger.info(e) + return user, False + + except LDAPSocketOpenError as e: + current_app.logger.info(e) + return abort(403, ErrFormat.ldap_connection_failed) diff --git a/acl-api/api/lib/perm/authentication/oauth2/__init__.py b/acl-api/api/lib/perm/authentication/oauth2/__init__.py new file mode 100644 index 0000000..1b7d02e --- /dev/null +++ b/acl-api/api/lib/perm/authentication/oauth2/__init__.py @@ -0,0 +1,30 @@ +# -*- coding:utf-8 -*- + +from flask import current_app + +from . import routing + + +class OAuth2(object): + def __init__(self, app=None, url_prefix=None): + self._app = app + if app is not None: + self.init_app(app, url_prefix) + + @staticmethod + def init_app(app, url_prefix=None): + # Configuration defaults + app.config.setdefault('OAUTH2_GRANT_TYPE', 'authorization_code') + app.config.setdefault('OAUTH2_RESPONSE_TYPE', 'code') + app.config.setdefault('OAUTH2_AFTER_LOGIN', '/') + + app.config.setdefault('OIDC_GRANT_TYPE', 'authorization_code') + app.config.setdefault('OIDC_RESPONSE_TYPE', 'code') + app.config.setdefault('OIDC_AFTER_LOGIN', '/') + + # Register Blueprint + app.register_blueprint(routing.blueprint, url_prefix=url_prefix) + + @property + def app(self): + return self._app or current_app diff --git a/acl-api/api/lib/perm/authentication/oauth2/routing.py b/acl-api/api/lib/perm/authentication/oauth2/routing.py new file mode 100644 index 0000000..dfc42d8 --- /dev/null +++ b/acl-api/api/lib/perm/authentication/oauth2/routing.py @@ -0,0 +1,139 @@ +# -*- coding:utf-8 -*- + +import datetime +import secrets +import uuid + +import requests +from flask import Blueprint +from flask import abort +from flask import current_app +from flask import redirect +from flask import request +from flask import session +from flask import url_for +from flask_login import login_user +from flask_login import logout_user +from six.moves.urllib.parse import urlencode +from six.moves.urllib.parse import urlparse + +from api.lib.common_setting.common_data import AuthenticateDataCRUD +from api.lib.perm.acl.audit import AuditCRUD +from api.lib.perm.acl.cache import UserCache +from api.lib.perm.acl.resp_format import ErrFormat + +blueprint = Blueprint('oauth2', __name__) + + +@blueprint.route('/api//login') +def login(auth_type): + config = AuthenticateDataCRUD(auth_type.upper()).get() + + if request.values.get("next"): + session["next"] = request.values.get("next") + + session[f'{auth_type}_state'] = secrets.token_urlsafe(16) + + auth_type = auth_type.upper() + + redirect_uri = "{}://{}{}".format(urlparse(request.referrer).scheme, + urlparse(request.referrer).netloc, + url_for('oauth2.callback', auth_type=auth_type.lower())) + qs = urlencode({ + 'client_id': config['client_id'], + 'redirect_uri': redirect_uri, + 'response_type': current_app.config[f'{auth_type}_RESPONSE_TYPE'], + 'scope': ' '.join(config['scopes'] or []), + 'state': session[f'{auth_type.lower()}_state'], + }) + + return redirect("{}?{}".format(config['authorize_url'].split('?')[0], qs)) + + +@blueprint.route('/api//callback') +def callback(auth_type): + auth_type = auth_type.upper() + config = AuthenticateDataCRUD(auth_type).get() + + redirect_url = session.get("next") or config.get('after_login') or '/' + + if request.values['state'] != session.get(f'{auth_type.lower()}_state'): + return abort(401, "state is invalid") + + if 'code' not in request.values: + return abort(401, 'code is invalid') + + response = requests.post(config['token_url'], data={ + 'client_id': config['client_id'], + 'client_secret': config['client_secret'], + 'code': request.values['code'], + 'grant_type': current_app.config[f'{auth_type}_GRANT_TYPE'], + 'redirect_uri': url_for('oauth2.callback', auth_type=auth_type.lower(), _external=True), + }, headers={'Accept': 'application/json'}) + if response.status_code != 200: + current_app.logger.error(response.text) + return abort(401) + access_token = response.json().get('access_token') + if not access_token: + return abort(401) + + response = requests.get(config['user_info']['url'], headers={ + 'Authorization': 'Bearer {}'.format(access_token), + 'Accept': 'application/json', + }) + if response.status_code != 200: + return abort(401) + + res = response.json() + email = res.get(config['user_info']['email']) + username = res.get(config['user_info']['username']) + avatar = res.get(config['user_info'].get('avatar')) + user = UserCache.get(username) + if user is None: + current_app.logger.info("create user: {}".format(username)) + from api.lib.perm.acl.user import UserCRUD + + user_dict = dict(username=username, email=email, avatar=avatar) + user_dict['password'] = uuid.uuid4().hex + + user = UserCRUD.add(**user_dict) + + # log the user in + login_user(user) + + from api.lib.perm.acl.acl import ACLManager + user_info = ACLManager.get_user_info(username) + + session["acl"] = dict(uid=user_info.get("uid"), + avatar=user.avatar if user else user_info.get("avatar"), + userId=user_info.get("uid"), + rid=user_info.get("rid"), + userName=user_info.get("username"), + nickName=user_info.get("nickname") or user_info.get("username"), + parentRoles=user_info.get("parents"), + childRoles=user_info.get("children"), + roleName=user_info.get("role")) + session["uid"] = user_info.get("uid") + + _id = AuditCRUD.add_login_log(username, True, ErrFormat.login_succeed) + session['LOGIN_ID'] = _id + + return redirect(redirect_url) + + +@blueprint.route('/api//logout') +def logout(auth_type): + "acl" in session and session.pop("acl") + "uid" in session and session.pop("uid") + f'{auth_type}_state' in session and session.pop(f'{auth_type}_state') + "next" in session and session.pop("next") + + redirect_url = url_for('oauth2.login', auth_type=auth_type, _external=True, next=request.referrer) + + logout_user() + + current_app.logger.debug('Redirecting to: {0}'.format(redirect_url)) + + AuditCRUD.add_login_log(None, None, None, _id=session.get('LOGIN_ID'), logout_at=datetime.datetime.now()) + + return redirect(redirect_url) diff --git a/acl-api/api/lib/resp_format.py b/acl-api/api/lib/resp_format.py index e3cf4da..18ff0c6 100644 --- a/acl-api/api/lib/resp_format.py +++ b/acl-api/api/lib/resp_format.py @@ -1,27 +1,34 @@ # -*- coding:utf-8 -*- +from flask_babel import lazy_gettext as _l + + class CommonErrFormat(object): - unauthorized = "未认证" - unknown_error = "未知错误" + unauthorized = _l("unauthorized") # 未认证 + unknown_error = _l("unknown error") # 未知错误 + + invalid_request = _l("Illegal request") # 不合法的请求 + invalid_operation = _l("Invalid operation") # 无效的操作 - invalid_request = "不合法的请求" - invalid_operation = "无效的操作" + not_found = _l("does not exist") # 不存在 - not_found = "不存在" + circular_dependency_error = _l("There is a circular dependency!") # 存在循环依赖! - unknown_search_error = "未知搜索错误" + unknown_search_error = _l("Unknown search error") # 未知搜索错误 - invalid_json = "json格式似乎不正确了, 请仔细确认一下!" + # json格式似乎不正确了, 请仔细确认一下! + invalid_json = _l("The json format seems to be incorrect, please confirm carefully!") - datetime_argument_invalid = "参数 {} 格式不正确, 格式必须是: yyyy-mm-dd HH:MM:SS" + # 参数 {} 格式不正确, 格式必须是: yyyy-mm-dd HH:MM:SS + datetime_argument_invalid = _l("The format of parameter {} is incorrect, the format must be: yyyy-mm-dd HH:MM:SS") - argument_value_required = "参数 {} 的值不能为空!" - argument_required = "请求缺少参数 {}" - argument_invalid = "参数 {} 的值无效" - argument_str_length_limit = "参数 {} 的长度必须 <= {}" + argument_value_required = _l("The value of parameter {} cannot be empty!") # 参数 {} 的值不能为空! + argument_required = _l("The request is missing parameters {}") # 请求缺少参数 {} + argument_invalid = _l("Invalid value for parameter {}") # 参数 {} 的值无效 + argument_str_length_limit = _l("The length of parameter {} must be <= {}") # 参数 {} 的长度必须 <= {} - role_required = "角色 {} 才能操作!" - user_not_found = "用户 {} 不存在" - no_permission = "您没有资源: {} 的{}权限!" - no_permission2 = "您没有操作权限!" - no_permission_only_owner = "只有创建人或者管理员才有权限!" + role_required = _l("Role {} can only operate!") # 角色 {} 才能操作! + user_not_found = _l("User {} does not exist") # 用户 {} 不存在 + no_permission = _l("For resource: {}, you do not have {} permission!") # 您没有资源: {} 的{}权限! + no_permission2 = _l("You do not have permission to operate!") # 您没有操作权限! + no_permission_only_owner = _l("Only the creator or administrator has permission!") # 只有创建人或者管理员才有权限! diff --git a/acl-api/api/lib/utils.py b/acl-api/api/lib/utils.py index 85d541a..a8482a8 100644 --- a/acl-api/api/lib/utils.py +++ b/acl-api/api/lib/utils.py @@ -1,11 +1,13 @@ # -*- coding:utf-8 -*- -import sys -import time +import base64 from typing import Set import redis import six +import sys +import time +from Crypto.Cipher import AES from flask import current_app @@ -108,7 +110,7 @@ def delete(self, key_id, prefix): try: ret = self.r.hdel(prefix, key_id) if not ret: - current_app.logger.warn("[{0}] is not in redis".format(key_id)) + current_app.logger.warning("[{0}] is not in redis".format(key_id)) except Exception as e: current_app.logger.error("delete redis key error, {0}".format(str(e))) @@ -157,3 +159,35 @@ def __enter__(self): def __exit__(self, exc_type, exc_val, exc_tb): if self.need_lock: self.release() + + +class AESCrypto(object): + BLOCK_SIZE = 16 # Bytes + pad = lambda s: s + ((AESCrypto.BLOCK_SIZE - len(s) % AESCrypto.BLOCK_SIZE) * + chr(AESCrypto.BLOCK_SIZE - len(s) % AESCrypto.BLOCK_SIZE)) + unpad = lambda s: s[:-ord(s[len(s) - 1:])] + + iv = '0102030405060708' + + @staticmethod + def key(): + key = current_app.config.get("SECRET_KEY")[:16] + if len(key) < 16: + key = "{}{}".format(key, (16 - len(key)) * "x") + + return key.encode('utf8') + + @classmethod + def encrypt(cls, data): + data = cls.pad(data) + cipher = AES.new(cls.key(), AES.MODE_CBC, cls.iv.encode('utf8')) + + return base64.b64encode(cipher.encrypt(data.encode('utf8'))).decode('utf8') + + @classmethod + def decrypt(cls, data): + encode_bytes = base64.decodebytes(data.encode('utf8')) + cipher = AES.new(cls.key(), AES.MODE_CBC, cls.iv.encode('utf8')) + text_decrypted = cipher.decrypt(encode_bytes) + + return cls.unpad(text_decrypted).decode('utf8') diff --git a/acl-api/api/models/acl.py b/acl-api/api/models/acl.py index ebf02ff..730a012 100644 --- a/acl-api/api/models/acl.py +++ b/acl-api/api/models/acl.py @@ -5,16 +5,18 @@ import hashlib from datetime import datetime -import ldap from flask import current_app +from flask import session from flask_sqlalchemy import BaseQuery from api.extensions import db from api.lib.database import CRUDModel from api.lib.database import Model +from api.lib.database import Model2 from api.lib.database import SoftDeleteMixin from api.lib.perm.acl.const import ACL_QUEUE from api.lib.perm.acl.const import OperateType +from api.lib.perm.acl.resp_format import ErrFormat class App(Model): @@ -27,21 +29,26 @@ class App(Model): class UserQuery(BaseQuery): - def _join(self, *args, **kwargs): - super(UserQuery, self)._join(*args, **kwargs) def authenticate(self, login, password): + from api.lib.perm.acl.audit import AuditCRUD + user = self.filter(db.or_(User.username == login, User.email == login)).filter(User.deleted.is_(False)).filter(User.block == 0).first() if user: - current_app.logger.info(user) authenticated = user.check_password(password) if authenticated: - from api.tasks.acl import op_record - op_record.apply_async(args=(None, login, OperateType.LOGIN, ["ACL"]), queue=ACL_QUEUE) + _id = AuditCRUD.add_login_log(login, True, ErrFormat.login_succeed) + session['LOGIN_ID'] = _id + else: + AuditCRUD.add_login_log(login, False, ErrFormat.invalid_password) else: authenticated = False + AuditCRUD.add_login_log(login, False, ErrFormat.user_not_found.format(login)) + + current_app.logger.info(("login", login, user, authenticated)) + return user, authenticated def authenticate_with_key(self, key, secret, args, path): @@ -56,37 +63,6 @@ def authenticate_with_key(self, key, secret, args, path): return user, authenticated - def authenticate_with_ldap(self, username, password): - ldap_conn = ldap.initialize(current_app.config.get('LDAP_SERVER')) - ldap_conn.protocol_version = 3 - ldap_conn.set_option(ldap.OPT_REFERRALS, 0) - if '@' in username: - email = username - who = '{0}@{1}'.format(username.split('@')[0], current_app.config.get('LDAP_DOMAIN')) - else: - who = '{0}@{1}'.format(username, current_app.config.get('LDAP_DOMAIN')) - email = who - - username = username.split('@')[0] - user = self.get_by_username(username) - try: - - if not password: - raise ldap.INVALID_CREDENTIALS - - ldap_conn.simple_bind_s(who, password) - - if not user: - from api.lib.perm.acl.user import UserCRUD - user = UserCRUD.add(username=username, email=email) - - from api.tasks.acl import op_record - op_record.apply_async(args=(None, username, OperateType.LOGIN, ["ACL"]), queue=ACL_QUEUE) - - return user, True - except ldap.INVALID_CREDENTIALS: - return user, False - def search(self, key): query = self.filter(db.or_(User.email == key, User.nickname.ilike('%' + key + '%'), @@ -136,6 +112,7 @@ class User(CRUDModel, SoftDeleteMixin): wx_id = db.Column(db.String(32)) employee_id = db.Column(db.String(16), index=True) avatar = db.Column(db.String(128)) + # apps = db.Column(db.JSON) def __str__(self): @@ -166,11 +143,9 @@ def check_password(self, password): class RoleQuery(BaseQuery): - def _join(self, *args, **kwargs): - super(RoleQuery, self)._join(*args, **kwargs) def authenticate(self, login, password): - role = self.filter(Role.name == login).first() + role = self.filter(Role.name == login).filter(Role.deleted.is_(False)).first() if role: authenticated = role.check_password(password) @@ -375,3 +350,16 @@ class AuditTriggerLog(Model): current = db.Column(db.JSON, default=dict(), comment='当前数据') extra = db.Column(db.JSON, default=dict(), comment='权限名') source = db.Column(db.String(16), default='', comment='来源') + + +class AuditLoginLog(Model2): + __tablename__ = "acl_audit_login_logs" + + username = db.Column(db.String(64), index=True) + channel = db.Column(db.Enum('web', 'api', 'ssh'), default="web") + ip = db.Column(db.String(15)) + browser = db.Column(db.String(256)) + description = db.Column(db.String(128)) + is_ok = db.Column(db.Boolean) + login_at = db.Column(db.DateTime) + logout_at = db.Column(db.DateTime) diff --git a/acl-api/api/models/common_setting.py b/acl-api/api/models/common_setting.py index 741f664..f1f5404 100644 --- a/acl-api/api/models/common_setting.py +++ b/acl-api/api/models/common_setting.py @@ -13,40 +13,41 @@ class Department(ModelWithoutPK): __tablename__ = 'common_department' department_id = db.Column(db.Integer, primary_key=True, autoincrement=True) - department_name = db.Column(db.VARCHAR(255), default='', comment='部门名称') + department_name = db.Column(db.VARCHAR(255), default='') department_director_id = db.Column( - db.Integer, default=0, comment='部门负责人ID') - department_parent_id = db.Column(db.Integer, default=1, comment='上级部门ID') + db.Integer, default=0) + department_parent_id = db.Column(db.Integer, default=1) - sort_value = db.Column(db.Integer, default=0, comment='排序值') + sort_value = db.Column(db.Integer, default=0) - acl_rid = db.Column(db.Integer, comment='ACL中rid', default=0) + acl_rid = db.Column(db.Integer, default=0) class Employee(ModelWithoutPK): __tablename__ = 'common_employee' employee_id = db.Column(db.Integer, primary_key=True, autoincrement=True) - email = db.Column(db.VARCHAR(255), default='', comment='邮箱') - username = db.Column(db.VARCHAR(255), default='', comment='用户名') - nickname = db.Column(db.VARCHAR(255), default='', comment='姓名') - sex = db.Column(db.VARCHAR(64), default='', comment='性别') - position_name = db.Column(db.VARCHAR(255), default='', comment='职位名称') - mobile = db.Column(db.VARCHAR(255), default='', comment='电话号码') - avatar = db.Column(db.VARCHAR(255), default='', comment='头像') + email = db.Column(db.VARCHAR(255), default='') + username = db.Column(db.VARCHAR(255), default='') + nickname = db.Column(db.VARCHAR(255), default='') + sex = db.Column(db.VARCHAR(64), default='') + position_name = db.Column(db.VARCHAR(255), default='') + mobile = db.Column(db.VARCHAR(255), default='') + avatar = db.Column(db.VARCHAR(255), default='') - direct_supervisor_id = db.Column(db.Integer, default=0, comment='直接上级ID') + direct_supervisor_id = db.Column(db.Integer, default=0) department_id = db.Column(db.Integer, - db.ForeignKey('common_department.department_id'), - comment='部门ID', + db.ForeignKey('common_department.department_id') ) - acl_uid = db.Column(db.Integer, comment='ACL中uid', default=0) - acl_rid = db.Column(db.Integer, comment='ACL中rid', default=0) - acl_virtual_rid = db.Column(db.Integer, comment='ACL中虚拟角色rid', default=0) - last_login = db.Column(db.TIMESTAMP, nullable=True, comment='上次登录时间') - block = db.Column(db.Integer, comment='锁定状态', default=0) + acl_uid = db.Column(db.Integer, default=0) + acl_rid = db.Column(db.Integer, default=0) + acl_virtual_rid = db.Column(db.Integer, default=0) + last_login = db.Column(db.TIMESTAMP, nullable=True) + block = db.Column(db.Integer, default=0) + + notice_info = db.Column(db.JSON, default={}) _department = db.relationship( 'Department', backref='common_employee.department_id', @@ -55,14 +56,11 @@ class Employee(ModelWithoutPK): class EmployeeInfo(Model): - """ - 员工信息 - """ __tablename__ = 'common_employee_info' - info = db.Column(db.JSON, default={}, comment='员工信息') + info = db.Column(db.JSON, default={}) employee_id = db.Column(db.Integer, db.ForeignKey( - 'common_employee.employee_id'), comment='员工ID') + 'common_employee.employee_id')) employee = db.relationship( 'Employee', backref='common_employee.employee_id', lazy='joined') @@ -74,16 +72,35 @@ class CompanyInfo(Model): class InternalMessage(Model): - """ - 内部消息 - """ __tablename__ = "common_internal_message" - title = db.Column(db.VARCHAR(255), nullable=True, comment='标题') - content = db.Column(db.TEXT, nullable=True, comment='内容') - path = db.Column(db.VARCHAR(255), nullable=True, comment='跳转路径') - is_read = db.Column(db.Boolean, default=False, comment='是否已读') - app_name = db.Column(db.VARCHAR(128), nullable=False, comment='应用名称') - category = db.Column(db.VARCHAR(128), nullable=False, comment='分类') - message_data = db.Column(db.JSON, nullable=True, comment='数据') + title = db.Column(db.VARCHAR(255), nullable=True) + content = db.Column(db.TEXT, nullable=True) + path = db.Column(db.VARCHAR(255), nullable=True) + is_read = db.Column(db.Boolean, default=False) + app_name = db.Column(db.VARCHAR(128), nullable=False) + category = db.Column(db.VARCHAR(128), nullable=False) + message_data = db.Column(db.JSON, nullable=True) employee_id = db.Column(db.Integer, db.ForeignKey('common_employee.employee_id'), comment='ID') + + +class CommonData(Model): + __table_name__ = 'common_data' + + data_type = db.Column(db.VARCHAR(255), default='') + data = db.Column(db.JSON) + + +class NoticeConfig(Model): + __tablename__ = "common_notice_config" + + platform = db.Column(db.VARCHAR(255), nullable=False) + info = db.Column(db.JSON) + + +class CommonFile(Model): + __tablename__ = 'common_file' + + file_name = db.Column(db.VARCHAR(512), nullable=False, index=True) + origin_name = db.Column(db.VARCHAR(512), nullable=False) + binary = db.Column(db.LargeBinary(16777216), nullable=False) diff --git a/acl-api/api/tasks/acl.py b/acl-api/api/tasks/acl.py index e083ffe..94f37b6 100644 --- a/acl-api/api/tasks/acl.py +++ b/acl-api/api/tasks/acl.py @@ -3,37 +3,43 @@ import json import re -from celery_once import QueueOnce +import redis_lock from flask import current_app -from werkzeug.exceptions import BadRequest, NotFound +from werkzeug.exceptions import BadRequest +from werkzeug.exceptions import NotFound from api.extensions import celery -from api.extensions import db +from api.extensions import rd +from api.lib.decorator import flush_db +from api.lib.decorator import reconnect_db +from api.lib.perm.acl.audit import AuditCRUD +from api.lib.perm.acl.audit import AuditOperateSource +from api.lib.perm.acl.audit import AuditOperateType from api.lib.perm.acl.cache import AppCache from api.lib.perm.acl.cache import RoleCache from api.lib.perm.acl.cache import RoleRelationCache from api.lib.perm.acl.cache import UserCache from api.lib.perm.acl.const import ACL_QUEUE from api.lib.perm.acl.record import OperateRecordCRUD -from api.lib.perm.acl.audit import AuditCRUD, AuditOperateType, AuditOperateSource from api.models.acl import Resource from api.models.acl import Role from api.models.acl import Trigger -@celery.task(base=QueueOnce, - name="acl.role_rebuild", - queue=ACL_QUEUE, - once={"graceful": True, "unlock_before_run": True}) +@celery.task(name="acl.role_rebuild", queue=ACL_QUEUE, ) +@flush_db +@reconnect_db def role_rebuild(rids, app_id): rids = rids if isinstance(rids, list) else [rids] for rid in rids: - RoleRelationCache.rebuild(rid, app_id) + with redis_lock.Lock(rd.r, "ROLE_REBUILD_{}_{}".format(rid, app_id)): + RoleRelationCache.rebuild(rid, app_id) current_app.logger.info("Role {0} App {1} rebuild..........".format(rids, app_id)) @celery.task(name="acl.update_resource_to_build_role", queue=ACL_QUEUE) +@reconnect_db def update_resource_to_build_role(resource_id, app_id, group_id=None): rids = [i.id for i in Role.get_by(__func_isnot__key_uid=None, fl='id', to_dict=False)] rids += [i.id for i in Role.get_by(app_id=app_id, fl='id', to_dict=False)] @@ -49,9 +55,9 @@ def update_resource_to_build_role(resource_id, app_id, group_id=None): @celery.task(name="acl.apply_trigger", queue=ACL_QUEUE) +@flush_db +@reconnect_db def apply_trigger(_id, resource_id=None, operator_uid=None): - db.session.remove() - from api.lib.perm.acl.permission import PermissionCRUD trigger = Trigger.get_by_id(_id) @@ -115,9 +121,9 @@ def apply_trigger(_id, resource_id=None, operator_uid=None): @celery.task(name="acl.cancel_trigger", queue=ACL_QUEUE) +@flush_db +@reconnect_db def cancel_trigger(_id, resource_id=None, operator_uid=None): - db.session.remove() - from api.lib.perm.acl.permission import PermissionCRUD trigger = Trigger.get_by_id(_id) @@ -183,18 +189,19 @@ def cancel_trigger(_id, resource_id=None, operator_uid=None): @celery.task(name="acl.op_record", queue=ACL_QUEUE) -def op_record(app, rolename, operate_type, obj): +@reconnect_db +def op_record(app, role_name, operate_type, obj): if isinstance(app, int): app = AppCache.get(app) app = app and app.name - if isinstance(rolename, int): - u = UserCache.get(rolename) + if isinstance(role_name, int): + u = UserCache.get(role_name) if u: - rolename = u.username + role_name = u.username if not u: - r = RoleCache.get(rolename) + r = RoleCache.get(role_name) if r: - rolename = r.name + role_name = r.name - OperateRecordCRUD.add(app, rolename, operate_type, obj) + OperateRecordCRUD.add(app, role_name, operate_type, obj) diff --git a/acl-api/api/tasks/common_setting.py b/acl-api/api/tasks/common_setting.py index ca0f669..053e70f 100644 --- a/acl-api/api/tasks/common_setting.py +++ b/acl-api/api/tasks/common_setting.py @@ -1,24 +1,24 @@ # -*- coding:utf-8 -*- -import requests from flask import current_app from api.extensions import celery -from api.extensions import db from api.lib.common_setting.acl import ACLManager -from api.lib.common_setting.const import COMMON_SETTING_QUEUE +from api.lib.perm.acl.const import ACL_QUEUE from api.lib.common_setting.resp_format import ErrFormat -from api.models.common_setting import Department +from api.models.common_setting import Department, Employee +from api.lib.decorator import flush_db +from api.lib.decorator import reconnect_db -@celery.task(name="common_setting.edit_employee_department_in_acl", queue=COMMON_SETTING_QUEUE) +@celery.task(name="common_setting.edit_employee_department_in_acl", queue=ACL_QUEUE) +@flush_db +@reconnect_db def edit_employee_department_in_acl(e_list, new_d_id, op_uid): """ :param e_list:{acl_rid: 11, department_id: 22} :param new_d_id :param op_uid """ - db.session.remove() - result = [] new_department = Department.get_by( first=True, department_id=new_d_id, to_dict=False) @@ -49,21 +49,20 @@ def edit_employee_department_in_acl(e_list, new_d_id, op_uid): continue old_d_rid_in_acl = role_map.get(old_department.department_name, 0) - if old_d_rid_in_acl == 0: - return - if old_d_rid_in_acl != old_department.acl_rid: - old_department.update( - acl_rid=old_d_rid_in_acl - ) - d_acl_rid = old_department.acl_rid if old_d_rid_in_acl == old_department.acl_rid else old_d_rid_in_acl - payload = { - 'app_id': 'acl', - 'parent_id': d_acl_rid, - } - try: - acl.remove_user_from_role(employee_acl_rid, payload) - except Exception as e: - result.append(ErrFormat.acl_remove_user_from_role_failed.format(str(e))) + if old_d_rid_in_acl > 0: + if old_d_rid_in_acl != old_department.acl_rid: + old_department.update( + acl_rid=old_d_rid_in_acl + ) + d_acl_rid = old_department.acl_rid if old_d_rid_in_acl == old_department.acl_rid else old_d_rid_in_acl + payload = { + 'app_id': 'acl', + 'parent_id': d_acl_rid, + } + try: + acl.remove_user_from_role(employee_acl_rid, payload) + except Exception as e: + result.append(ErrFormat.acl_remove_user_from_role_failed.format(str(e))) payload = { 'app_id': 'acl', @@ -75,3 +74,57 @@ def edit_employee_department_in_acl(e_list, new_d_id, op_uid): result.append(ErrFormat.acl_add_user_to_role_failed.format(str(e))) return result + + +@celery.task(name="common_setting.refresh_employee_acl_info", queue=ACL_QUEUE) +@flush_db +@reconnect_db +def refresh_employee_acl_info(current_employee_id=None): + acl = ACLManager('acl') + role_map = {role['name']: role for role in acl.get_all_roles()} + + criterion = [ + Employee.deleted == 0 + ] + query = Employee.query.filter(*criterion).order_by( + Employee.created_at.desc() + ) + current_employee_rid = 0 + + for em in query.all(): + if current_employee_id and em.employee_id == current_employee_id: + current_employee_rid = em.acl_rid if em.acl_rid else 0 + + if em.acl_uid and em.acl_rid: + continue + role = role_map.get(em.username, None) + if not role: + continue + + params = dict() + if not em.acl_uid: + params['acl_uid'] = role.get('uid', 0) + + if not em.acl_rid: + params['acl_rid'] = role.get('id', 0) + + if current_employee_id and em.employee_id == current_employee_id: + current_employee_rid = params['acl_rid'] if params.get('acl_rid', 0) else 0 + + try: + em.update(**params) + current_app.logger.info( + f"refresh_employee_acl_info success, employee_id: {em.employee_id}, uid: {em.acl_uid}, " + f"rid: {em.acl_rid}") + except Exception as e: + current_app.logger.error(str(e)) + continue + + if current_employee_rid and current_employee_rid > 0: + try: + from api.lib.common_setting.employee import GrantEmployeeACLPerm + + GrantEmployeeACLPerm().grant_by_rid(current_employee_rid, False) + current_app.logger.info(f"GrantEmployeeACLPerm success, current_employee_rid: {current_employee_rid}") + except Exception as e: + current_app.logger.error(str(e)) diff --git a/acl-api/api/translations/zh/LC_MESSAGES/messages.mo b/acl-api/api/translations/zh/LC_MESSAGES/messages.mo new file mode 100644 index 0000000..aba4214 Binary files /dev/null and b/acl-api/api/translations/zh/LC_MESSAGES/messages.mo differ diff --git a/acl-api/api/translations/zh/LC_MESSAGES/messages.po b/acl-api/api/translations/zh/LC_MESSAGES/messages.po new file mode 100644 index 0000000..741e9fd --- /dev/null +++ b/acl-api/api/translations/zh/LC_MESSAGES/messages.po @@ -0,0 +1,525 @@ +# Chinese translations for PROJECT. +# Copyright (C) 2024 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , 2024. +# +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2025-08-16 23:41+0800\n" +"PO-Revision-Date: 2024-01-25 17:19+0800\n" +"Last-Translator: FULL NAME \n" +"Language: zh\n" +"Language-Team: zh \n" +"Plural-Forms: nplurals=1; plural=0;\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.17.0\n" + +#: api/lib/resp_format.py:7 +msgid "unauthorized" +msgstr "未认证" + +#: api/lib/resp_format.py:8 +msgid "unknown error" +msgstr "未知错误" + +#: api/lib/resp_format.py:10 +msgid "Illegal request" +msgstr "不合法的请求" + +#: api/lib/resp_format.py:11 +msgid "Invalid operation" +msgstr "无效的操作" + +#: api/lib/resp_format.py:13 +msgid "does not exist" +msgstr "不存在" + +#: api/lib/resp_format.py:15 +msgid "There is a circular dependency!" +msgstr "存在循环依赖!" + +#: api/lib/resp_format.py:17 +msgid "Unknown search error" +msgstr "未知搜索错误" + +#: api/lib/resp_format.py:20 +msgid "The json format seems to be incorrect, please confirm carefully!" +msgstr "# json格式似乎不正确了, 请仔细确认一下!" + +#: api/lib/resp_format.py:23 +msgid "" +"The format of parameter {} is incorrect, the format must be: yyyy-mm-dd " +"HH:MM:SS" +msgstr "参数 {} 格式不正确, 格式必须是: yyyy-mm-dd HH:MM:SS" + +#: api/lib/resp_format.py:25 +msgid "The value of parameter {} cannot be empty!" +msgstr "参数 {} 的值不能为空!" + +#: api/lib/resp_format.py:26 +msgid "The request is missing parameters {}" +msgstr "请求缺少参数 {}" + +#: api/lib/resp_format.py:27 +msgid "Invalid value for parameter {}" +msgstr "参数 {} 的值无效" + +#: api/lib/resp_format.py:28 +msgid "The length of parameter {} must be <= {}" +msgstr "参数 {} 的长度必须 <= {}" + +#: api/lib/resp_format.py:30 +msgid "Role {} can only operate!" +msgstr "角色 {} 才能操作!" + +#: api/lib/resp_format.py:31 +msgid "User {} does not exist" +msgstr "用户 {} 不存在" + +#: api/lib/resp_format.py:32 +msgid "For resource: {}, you do not have {} permission!" +msgstr "您没有资源: {} 的{}权限!" + +#: api/lib/resp_format.py:33 +msgid "You do not have permission to operate!" +msgstr "您没有操作权限!" + +#: api/lib/resp_format.py:34 +msgid "Only the creator or administrator has permission!" +msgstr "只有创建人或者管理员才有权限!" + +#: api/lib/common_setting/resp_format.py:8 +msgid "Company info already existed" +msgstr "公司信息已存在,无法创建!" + +#: api/lib/common_setting/resp_format.py:10 +msgid "No file part" +msgstr "没有文件部分" + +#: api/lib/common_setting/resp_format.py:11 +msgid "File is required" +msgstr "文件是必须的" + +#: api/lib/common_setting/resp_format.py:12 +msgid "File not found" +msgstr "文件不存在!" + +#: api/lib/common_setting/resp_format.py:13 +msgid "File type not allowed" +msgstr "文件类型不允许!" + +#: api/lib/common_setting/resp_format.py:14 +#, python-brace-format +msgid "Upload failed: {}" +msgstr "上传失败: {}" + +#: api/lib/common_setting/resp_format.py:16 +msgid "Direct supervisor is not self" +msgstr "直属上级不能是自己" + +#: api/lib/common_setting/resp_format.py:17 +msgid "Parent department is not self" +msgstr "上级部门不能是自己" + +#: api/lib/common_setting/resp_format.py:18 +msgid "Employee list is empty" +msgstr "员工列表为空" + +#: api/lib/common_setting/resp_format.py:20 +msgid "Column name not support" +msgstr "不支持的列名" + +#: api/lib/common_setting/resp_format.py:21 +msgid "Password is required" +msgstr "密码是必须的" + +#: api/lib/common_setting/resp_format.py:22 +msgid "Employee acl rid is zero" +msgstr "员工ACL角色ID不能为0" + +#: api/lib/common_setting/resp_format.py:24 +#, python-brace-format +msgid "Generate excel failed: {}" +msgstr "生成excel失败: {}" + +#: api/lib/common_setting/resp_format.py:25 +#, python-brace-format +msgid "Rename columns failed: {}" +msgstr "重命名字段失败: {}" + +#: api/lib/common_setting/resp_format.py:27 +msgid "Cannot block this employee is other direct supervisor" +msgstr "该员工是其他员工的直属上级, 不能禁用" + +#: api/lib/common_setting/resp_format.py:29 +msgid "Cannot block this employee is department manager" +msgstr "该员工是部门负责人, 不能禁用" + +#: api/lib/common_setting/resp_format.py:30 +#, python-brace-format +msgid "Employee id [{}] not found" +msgstr "员工ID [{}] 不存在!" + +#: api/lib/common_setting/resp_format.py:31 +msgid "Value is required" +msgstr "值是必须的" + +#: api/lib/common_setting/resp_format.py:32 +msgid "Email already exists" +msgstr "邮箱已存在!" + +#: api/lib/common_setting/resp_format.py:33 +#, python-brace-format +msgid "Query {} none keep value empty" +msgstr "查询 {} 空值时请保持value为空" + +#: api/lib/common_setting/resp_format.py:34 +#, python-brace-format +msgid "Not support operator: {}" +msgstr "不支持的操作符: {}" + +#: api/lib/common_setting/resp_format.py:35 +#, python-brace-format +msgid "Not support relation: {}" +msgstr "不支持的关系: {}" + +#: api/lib/common_setting/resp_format.py:36 +msgid "Conditions field missing" +msgstr " conditions内元素字段缺失,请检查!" + +#: api/lib/common_setting/resp_format.py:37 +#, python-brace-format +msgid "Datetime format error: {}" +msgstr "{}格式错误,应该为:%Y-%m-%d %H:%M:%S" + +#: api/lib/common_setting/resp_format.py:38 +msgid "Department level relation error" +msgstr "部门层级关系不正确" + +#: api/lib/common_setting/resp_format.py:39 +msgid "Delete reserved department name" +msgstr "保留部门,无法删除!" + +#: api/lib/common_setting/resp_format.py:40 +msgid "Department id is required" +msgstr "部门ID是必须的" + +#: api/lib/common_setting/resp_format.py:41 +msgid "Department list is required" +msgstr "部门列表是必须的" + +#: api/lib/common_setting/resp_format.py:42 +#, python-brace-format +msgid "{} Cannot to be parent department" +msgstr "{} 不能设置为上级部门" + +#: api/lib/common_setting/resp_format.py:43 +#, python-brace-format +msgid "Department id [{}] not found" +msgstr "部门ID [{}] 不存在" + +#: api/lib/common_setting/resp_format.py:44 +msgid "Parent department id must more than zero" +msgstr "上级部门ID必须大于0" + +#: api/lib/common_setting/resp_format.py:45 +#, python-brace-format +msgid "Department name [{}] already exists" +msgstr "部门名称 [{}] 已存在" + +#: api/lib/common_setting/resp_format.py:46 +msgid "New department is none" +msgstr "新部门是空的" + +#: api/lib/common_setting/resp_format.py:48 +#, python-brace-format +msgid "ACL edit user failed: {}" +msgstr "ACL 修改用户失败: {}" + +#: api/lib/common_setting/resp_format.py:49 +#, python-brace-format +msgid "ACL uid not found: {}" +msgstr "ACL 用户UID [{}] 不存在" + +#: api/lib/common_setting/resp_format.py:50 +#, python-brace-format +msgid "ACL add user failed: {}" +msgstr "ACL 添加用户失败: {}" + +#: api/lib/common_setting/resp_format.py:51 +#, python-brace-format +msgid "ACL add role failed: {}" +msgstr "ACL 添加角色失败: {}" + +#: api/lib/common_setting/resp_format.py:52 +#, python-brace-format +msgid "ACL update role failed: {}" +msgstr "ACL 更新角色失败: {}" + +#: api/lib/common_setting/resp_format.py:53 +#, python-brace-format +msgid "ACL get all users failed: {}" +msgstr "ACL 获取所有用户失败: {}" + +#: api/lib/common_setting/resp_format.py:54 +#, python-brace-format +msgid "ACL remove user from role failed: {}" +msgstr "ACL 从角色中移除用户失败: {}" + +#: api/lib/common_setting/resp_format.py:55 +#, python-brace-format +msgid "ACL add user to role failed: {}" +msgstr "ACL 添加用户到角色失败: {}" + +#: api/lib/common_setting/resp_format.py:56 +#, python-brace-format +msgid "ACL import user failed: {}" +msgstr "ACL 导入用户失败: {}" + +#: api/lib/common_setting/resp_format.py:58 +msgid "Nickname is required" +msgstr "昵称不能为空" + +#: api/lib/common_setting/resp_format.py:59 +msgid "Username is required" +msgstr "用户名不能为空" + +#: api/lib/common_setting/resp_format.py:60 +msgid "Email is required" +msgstr "邮箱不能为空" + +#: api/lib/common_setting/resp_format.py:61 +msgid "Email format error" +msgstr "邮箱格式错误" + +#: api/lib/common_setting/resp_format.py:62 +msgid "Email send timeout" +msgstr "邮件发送超时" + +#: api/lib/common_setting/resp_format.py:64 +#, python-brace-format +msgid "Common data not found {} " +msgstr "ID {} 找不到记录" + +#: api/lib/common_setting/resp_format.py:65 +#, python-brace-format +msgid "Common data {} already existed" +msgstr "{} 已经存在" + +#: api/lib/common_setting/resp_format.py:66 +#, python-brace-format +msgid "Notice platform {} existed" +msgstr "{} 已经存在" + +#: api/lib/common_setting/resp_format.py:67 +#, python-brace-format +msgid "Notice {} not existed" +msgstr "{} 配置项不存在" + +#: api/lib/common_setting/resp_format.py:68 +msgid "Notice please config messenger first" +msgstr "请先配置messenger URL" + +#: api/lib/common_setting/resp_format.py:69 +msgid "Notice bind err with empty mobile" +msgstr "绑定错误,手机号为空" + +#: api/lib/common_setting/resp_format.py:70 +#, python-brace-format +msgid "Notice bind failed: {}" +msgstr "绑定失败: {}" + +#: api/lib/common_setting/resp_format.py:71 +msgid "Notice bind success" +msgstr "绑定成功" + +#: api/lib/common_setting/resp_format.py:72 +msgid "Notice remove bind success" +msgstr "解绑成功" + +#: api/lib/common_setting/resp_format.py:74 +#, python-brace-format +msgid "Not support test type: {}" +msgstr "不支持的测试类型: {}" + +#: api/lib/common_setting/resp_format.py:75 +#, python-brace-format +msgid "Not support auth type: {}" +msgstr "不支持的认证类型: {}" + +#: api/lib/common_setting/resp_format.py:76 +msgid "LDAP server connect timeout" +msgstr "LDAP服务器连接超时" + +#: api/lib/common_setting/resp_format.py:77 +msgid "LDAP server connect not available" +msgstr "LDAP服务器连接不可用" + +#: api/lib/common_setting/resp_format.py:78 +#, python-brace-format +msgid "LDAP test unknown error: {}" +msgstr "LDAP测试未知错误: {}" + +#: api/lib/common_setting/resp_format.py:79 +#, python-brace-format +msgid "Common data not support auth type: {}" +msgstr "通用数据不支持auth类型: {}" + +#: api/lib/common_setting/resp_format.py:80 +msgid "LDAP test username required" +msgstr "LDAP测试用户名必填" + +#: api/lib/common_setting/resp_format.py:82 +msgid "Company wide" +msgstr "全公司" + +#: api/lib/common_setting/resp_format.py:84 +msgid "No permission to access resource {}, perm {} " +msgstr "您没有资源: {} 的 {} 权限" + +#: api/lib/perm/acl/resp_format.py:9 +msgid "login successful" +msgstr "登录成功" + +#: api/lib/perm/acl/resp_format.py:10 +msgid "Failed to connect to LDAP service" +msgstr "连接LDAP服务失败" + +#: api/lib/perm/acl/resp_format.py:11 +msgid "Password verification failed" +msgstr "密码验证失败" + +#: api/lib/perm/acl/resp_format.py:12 +msgid "Application Token verification failed" +msgstr "应用 Token验证失败" + +#: api/lib/perm/acl/resp_format.py:15 +msgid "" +"You are not the application administrator or the session has expired (try" +" logging out and logging in again)" +msgstr "您不是应用管理员 或者 session失效(尝试一下退出重新登录)" + +#: api/lib/perm/acl/resp_format.py:17 +#, python-brace-format +msgid "Resource type {} does not exist!" +msgstr "资源类型 {} 不存在!" + +#: api/lib/perm/acl/resp_format.py:18 +#, python-brace-format +msgid "Resource type {} already exists!" +msgstr "资源类型 {} 已经存在!" + +#: api/lib/perm/acl/resp_format.py:20 +msgid "Because there are resources under this type, they cannot be deleted!" +msgstr "因为该类型下有资源的存在, 不能删除!" + +#: api/lib/perm/acl/resp_format.py:22 +#, python-brace-format +msgid "User {} does not exist!" +msgstr "用户 {} 不存在!" + +#: api/lib/perm/acl/resp_format.py:23 +#, python-brace-format +msgid "User {} already exists!" +msgstr "用户 {} 已经存在!" + +#: api/lib/perm/acl/resp_format.py:24 +#, python-brace-format +msgid "Role {} does not exist!" +msgstr "角色 {} 不存在!" + +#: api/lib/perm/acl/resp_format.py:25 +#, python-brace-format +msgid "Role {} already exists!" +msgstr "角色 {} 已经存在!" + +#: api/lib/perm/acl/resp_format.py:26 +#, python-brace-format +msgid "Global role {} does not exist!" +msgstr "全局角色 {} 不存在!" + +#: api/lib/perm/acl/resp_format.py:27 +#, python-brace-format +msgid "Global role {} already exists!" +msgstr "全局角色 {} 已经存在!" + +#: api/lib/perm/acl/resp_format.py:29 +#, python-brace-format +msgid "You do not have {} permission on resource: {}" +msgstr "您没有资源: {} 的 {} 权限" + +#: api/lib/perm/acl/resp_format.py:30 +msgid "Requires administrator permissions" +msgstr "需要管理员权限" + +#: api/lib/perm/acl/resp_format.py:31 +#, python-brace-format +msgid "Requires role: {}" +msgstr "需要角色: {}" + +#: api/lib/perm/acl/resp_format.py:33 +msgid "To delete a user role, please operate on the User Management page!" +msgstr "删除用户角色, 请在 用户管理 页面操作!" + +#: api/lib/perm/acl/resp_format.py:35 +#, python-brace-format +msgid "Application {} already exists" +msgstr "应用 {} 已经存在" + +#: api/lib/perm/acl/resp_format.py:36 +#, python-brace-format +msgid "Application {} does not exist!" +msgstr "应用 {} 不存在!" + +#: api/lib/perm/acl/resp_format.py:37 +msgid "The Secret is invalid" +msgstr "应用的Secret无效" + +#: api/lib/perm/acl/resp_format.py:39 +#, python-brace-format +msgid "Resource {} does not exist!" +msgstr "资源 {} 不存在!" + +#: api/lib/perm/acl/resp_format.py:40 +#, python-brace-format +msgid "Resource {} already exists!" +msgstr "资源 {} 已经存在!" + +#: api/lib/perm/acl/resp_format.py:42 +#, python-brace-format +msgid "Resource group {} does not exist!" +msgstr "资源组 {} 不存在!" + +#: api/lib/perm/acl/resp_format.py:43 +#, python-brace-format +msgid "Resource group {} already exists!" +msgstr "资源组 {} 已经存在!" + +#: api/lib/perm/acl/resp_format.py:45 +msgid "Inheritance detected infinite loop" +msgstr "继承检测到了死循环" + +#: api/lib/perm/acl/resp_format.py:46 +#, python-brace-format +msgid "Role relationship {} does not exist!" +msgstr "角色关系 {} 不存在!" + +#: api/lib/perm/acl/resp_format.py:48 +#, python-brace-format +msgid "Trigger {} does not exist!" +msgstr "触发器 {} 不存在!" + +#: api/lib/perm/acl/resp_format.py:49 +#, python-brace-format +msgid "Trigger {} already exists!" +msgstr "触发器 {} 已经存在!" + +#: api/lib/perm/acl/resp_format.py:50 +#, python-brace-format +msgid "Trigger {} has been disabled!" +msgstr "Trigger {} has been disabled!" + diff --git a/acl-api/api/views/acl/audit.py b/acl-api/api/views/acl/audit.py index ae4c20e..9826bb9 100644 --- a/acl-api/api/views/acl/audit.py +++ b/acl-api/api/views/acl/audit.py @@ -24,6 +24,7 @@ def get(self, name): 'role': AuditCRUD.search_role, 'trigger': AuditCRUD.search_trigger, 'resource': AuditCRUD.search_resource, + 'login': AuditCRUD.search_login, } if name not in func_map: abort(400, f'wrong {name}, please use {func_map.keys()}') diff --git a/acl-api/api/views/acl/login.py b/acl-api/api/views/acl/login.py index 09ee89a..d1669ce 100644 --- a/acl-api/api/views/acl/login.py +++ b/acl-api/api/views/acl/login.py @@ -1,22 +1,27 @@ # -*- coding:utf-8 -*- import datetime - import jwt import six from flask import abort from flask import current_app from flask import request from flask import session -from flask_login import login_user, logout_user +from flask_login import login_user +from flask_login import logout_user +from api.lib.common_setting.common_data import AuthenticateDataCRUD +from api.lib.common_setting.const import AuthenticateType from api.lib.decorator import args_required from api.lib.decorator import args_validate from api.lib.perm.acl.acl import ACLManager +from api.lib.perm.acl.audit import AuditCRUD +from api.lib.perm.acl.cache import AppCache from api.lib.perm.acl.cache import RoleCache from api.lib.perm.acl.cache import User from api.lib.perm.acl.cache import UserCache from api.lib.perm.acl.resp_format import ErrFormat +from api.lib.perm.acl.role import RoleRelationCRUD from api.lib.perm.auth import auth_abandoned from api.lib.perm.auth import auth_with_app_token from api.models.acl import Role @@ -34,8 +39,11 @@ def post(self): username = request.values.get("username") or request.values.get("email") password = request.values.get("password") _role = None - if current_app.config.get('AUTH_WITH_LDAP'): - user, authenticated = User.query.authenticate_with_ldap(username, password) + auth_with_ldap = request.values.get('auth_with_ldap', True) + config = AuthenticateDataCRUD(AuthenticateType.LDAP).get() + if (config.get('enabled') or config.get('enable')) and auth_with_ldap: + from api.lib.perm.authentication.ldap import authenticate_with_ldap + user, authenticated = authenticate_with_ldap(username, password) else: user, authenticated = User.query.authenticate(username, password) if not user: @@ -117,10 +125,17 @@ def post(self): if not user.get('username'): user['username'] = user.get('name') - return self.jsonify(user=user, - authenticated=authenticated, - rid=role and role.id, - can_proxy=can_proxy) + result = dict(user=user, + authenticated=authenticated, + rid=role and role.id, + can_proxy=can_proxy) + + if request.values.get('need_parentRoles') in current_app.config.get('BOOL_TRUE'): + app_id = AppCache.get(request.values.get('app_id')) + parent_ids = RoleRelationCRUD.recursive_parent_ids(role and role.id, app_id and app_id.id) + result['user']['parentRoles'] = [RoleCache.get(rid).name for rid in set(parent_ids) if RoleCache.get(rid)] + + return self.jsonify(result) class AuthWithTokenView(APIView): @@ -176,4 +191,9 @@ class LogoutView(APIView): @auth_abandoned def post(self): logout_user() + + AuditCRUD.add_login_log(None, None, None, + _id=session.get('LOGIN_ID') or request.values.get('LOGIN_ID'), + logout_at=datetime.datetime.now()) + self.jsonify(code=200) diff --git a/acl-api/api/views/acl/resources.py b/acl-api/api/views/acl/resources.py index df080b8..4a15a0b 100644 --- a/acl-api/api/views/acl/resources.py +++ b/acl-api/api/views/acl/resources.py @@ -1,6 +1,5 @@ # -*- coding:utf-8 -*- -from flask import g from flask import request from flask_login import current_user @@ -104,7 +103,7 @@ def post(self): type_id = request.values.get('type_id') app_id = request.values.get('app_id') uid = request.values.get('uid') - if not uid and hasattr(g, "user") and hasattr(current_user, "uid"): + if not uid and hasattr(current_user, "uid"): uid = current_user.uid resource = ResourceCRUD.add(name, type_id, app_id, uid) diff --git a/acl-api/api/views/acl/user.py b/acl-api/api/views/acl/user.py index d5e0d86..51bcca5 100644 --- a/acl-api/api/views/acl/user.py +++ b/acl-api/api/views/acl/user.py @@ -4,7 +4,6 @@ import requests from flask import abort from flask import current_app -from flask import g from flask import request from flask import session from flask_login import current_user @@ -12,6 +11,7 @@ from api.lib.decorator import args_required from api.lib.decorator import args_validate from api.lib.perm.acl.acl import ACLManager +from api.lib.perm.acl.acl import AuditCRUD from api.lib.perm.acl.acl import role_required from api.lib.perm.acl.cache import AppCache from api.lib.perm.acl.cache import UserCache @@ -49,6 +49,13 @@ def get(self): role=dict(permissions=user_info.get('parents')), avatar=user_info.get('avatar')) + if request.values.get('channel'): + _id = AuditCRUD.add_login_log(name, True, ErrFormat.login_succeed, + ip=request.values.get('ip'), + browser=request.values.get('browser')) + session['LOGIN_ID'] = _id + result['LOGIN_ID'] = _id + current_app.logger.info("get user info for3: {}".format(result)) return self.jsonify(result=result) @@ -161,7 +168,7 @@ def post(self): if app.name not in ('cas-server', 'acl'): return abort(403, ErrFormat.invalid_request) - elif hasattr(g, 'user'): + elif hasattr(current_user, 'username'): if current_user.username != request.values['username']: return abort(403, ErrFormat.invalid_request) diff --git a/acl-api/api/views/common_setting/auth_config.py b/acl-api/api/views/common_setting/auth_config.py new file mode 100644 index 0000000..5ec2840 --- /dev/null +++ b/acl-api/api/views/common_setting/auth_config.py @@ -0,0 +1,88 @@ +from flask import abort, request + +from api.lib.common_setting.common_data import AuthenticateDataCRUD +from api.lib.common_setting.const import TestType +from api.lib.common_setting.resp_format import ErrFormat +from api.lib.perm.acl.acl import role_required +from api.resource import APIView + +prefix = '/auth_config' + + +class AuthConfigView(APIView): + url_prefix = (f'{prefix}/',) + + @role_required("acl_admin") + def get(self, auth_type): + cli = AuthenticateDataCRUD(auth_type) + + if auth_type not in cli.get_support_type_list(): + abort(400, ErrFormat.not_support_auth_type.format(auth_type)) + + if auth_type in cli.common_type_list: + data = cli.get_record(True) + else: + data = cli.get_record_with_decrypt() + return self.jsonify(data) + + @role_required("acl_admin") + def post(self, auth_type): + cli = AuthenticateDataCRUD(auth_type) + + if auth_type not in cli.get_support_type_list(): + abort(400, ErrFormat.not_support_auth_type.format(auth_type)) + + params = request.json + data = params.get('data', {}) + if auth_type in cli.common_type_list: + data['encrypt'] = False + cli.create(data) + + return self.jsonify(params) + + +class AuthConfigViewWithId(APIView): + url_prefix = (f'{prefix}//',) + + @role_required("acl_admin") + def put(self, auth_type, _id): + cli = AuthenticateDataCRUD(auth_type) + + if auth_type not in cli.get_support_type_list(): + abort(400, ErrFormat.not_support_auth_type.format(auth_type)) + + params = request.json + data = params.get('data', {}) + if auth_type in cli.common_type_list: + data['encrypt'] = False + + res = cli.update(_id, data) + + return self.jsonify(res.to_dict()) + + @role_required("acl_admin") + def delete(self, auth_type, _id): + cli = AuthenticateDataCRUD(auth_type) + + if auth_type not in cli.get_support_type_list(): + abort(400, ErrFormat.not_support_auth_type.format(auth_type)) + cli.delete(_id) + return self.jsonify({}) + + +class AuthEnableListView(APIView): + url_prefix = (f'{prefix}/enable_list',) + + method_decorators = [] + + def get(self): + return self.jsonify(AuthenticateDataCRUD.get_enable_list()) + + +class AuthConfigTestView(APIView): + url_prefix = (f'{prefix}//test',) + + def post(self, auth_type): + test_type = request.values.get('test_type', TestType.Connect) + params = request.json + return self.jsonify(AuthenticateDataCRUD(auth_type).test(test_type, params.get('data'))) diff --git a/acl-api/api/views/common_setting/common_data.py b/acl-api/api/views/common_setting/common_data.py new file mode 100644 index 0000000..6d44ba1 --- /dev/null +++ b/acl-api/api/views/common_setting/common_data.py @@ -0,0 +1,35 @@ +from flask import request + +from api.lib.common_setting.common_data import CommonDataCRUD +from api.resource import APIView + +prefix = '/data' + + +class DataView(APIView): + url_prefix = (f'{prefix}/',) + + def get(self, data_type): + data_list = CommonDataCRUD.get_data_by_type(data_type) + + return self.jsonify(data_list) + + def post(self, data_type): + params = request.json + CommonDataCRUD.create_new_data(data_type, **params) + + return self.jsonify(params) + + +class DataViewWithId(APIView): + url_prefix = (f'{prefix}//',) + + def put(self, data_type, _id): + params = request.json + res = CommonDataCRUD.update_data(_id, **params) + + return self.jsonify(res.to_dict()) + + def delete(self, data_type, _id): + CommonDataCRUD.delete(_id) + return self.jsonify({}) diff --git a/acl-api/api/views/common_setting/company_info.py b/acl-api/api/views/common_setting/company_info.py index d2aca2a..4298cfd 100644 --- a/acl-api/api/views/common_setting/company_info.py +++ b/acl-api/api/views/common_setting/company_info.py @@ -1,9 +1,7 @@ # -*- coding:utf-8 -*- -from flask import abort from flask import request from api.lib.common_setting.company_info import CompanyInfoCRUD -from api.lib.common_setting.resp_format import ErrFormat from api.resource import APIView prefix = '/company' @@ -16,15 +14,16 @@ def get(self): return self.jsonify(CompanyInfoCRUD.get()) def post(self): - info = CompanyInfoCRUD.get() - if info: - abort(400, ErrFormat.company_info_is_already_existed) data = { 'info': { **request.values } } - d = CompanyInfoCRUD.create(**data) + info = CompanyInfoCRUD.get() + if info: + d = CompanyInfoCRUD.update(info.get('id'), **data) + else: + d = CompanyInfoCRUD.create(**data) res = d.to_dict() return self.jsonify(res) diff --git a/acl-api/api/views/common_setting/department.py b/acl-api/api/views/common_setting/department.py index 9a8dd4a..ba4091a 100644 --- a/acl-api/api/views/common_setting/department.py +++ b/acl-api/api/views/common_setting/department.py @@ -62,7 +62,7 @@ def post(self): class DepartmentIDView(APIView): url_prefix = (f'{prefix}/',) - def get(self, _id): + def put(self, _id): form = DepartmentForm(MultiDict(request.json)) if not form.validate(): abort(400, ','.join(['{}: {}'.format(filed, ','.join(msg)) diff --git a/acl-api/api/views/common_setting/employee.py b/acl-api/api/views/common_setting/employee.py index 56adf8b..173dc13 100644 --- a/acl-api/api/views/common_setting/employee.py +++ b/acl-api/api/views/common_setting/employee.py @@ -1,7 +1,5 @@ # -*- coding:utf-8 -*- -import os - -from flask import abort, current_app, send_from_directory +from flask import abort from flask import request from werkzeug.datastructures import MultiDict @@ -145,3 +143,26 @@ def get(self): result = EmployeeCRUD.get_all_position() return self.jsonify(result) + +class GetEmployeeNoticeByIds(APIView): + url_prefix = (f'{prefix}/get_notice_by_ids',) + + def post(self): + employee_ids = request.json.get('employee_ids', []) + if not employee_ids: + result = [] + else: + result = EmployeeCRUD.get_employee_notice_by_ids(employee_ids) + return self.jsonify(result) + + +class EmployeeBindNoticeWithACLID(APIView): + url_prefix = (f'{prefix}/by_uid/bind_notice//',) + + def put(self, platform, _uid): + data = EmployeeCRUD.bind_notice_by_uid(platform, _uid) + return self.jsonify(info=data) + + def delete(self, platform, _uid): + data = EmployeeCRUD.remove_bind_notice_by_uid(platform, _uid) + return self.jsonify(info=data) diff --git a/acl-api/api/views/common_setting/file_manage.py b/acl-api/api/views/common_setting/file_manage.py index 23e0047..fb304df 100644 --- a/acl-api/api/views/common_setting/file_manage.py +++ b/acl-api/api/views/common_setting/file_manage.py @@ -1,17 +1,18 @@ # -*- coding:utf-8 -*- -import os - -from flask import request, abort, current_app, send_from_directory +from flask import request, abort, current_app from werkzeug.utils import secure_filename +import lz4.frame +import magic +from api.lib.common_setting.const import MIMEExtMap from api.lib.common_setting.resp_format import ErrFormat -from api.lib.common_setting.upload_file import allowed_file, generate_new_file_name +from api.lib.common_setting.upload_file import allowed_file, generate_new_file_name, CommonFileCRUD from api.resource import APIView prefix = '/file' ALLOWED_EXTENSIONS = { - 'txt', 'pdf', 'png', 'jpg', 'jpeg', 'gif', 'xls', 'xlsx', 'doc', 'docx', 'ppt', 'pptx', 'csv' + 'txt', 'pdf', 'png', 'jpg', 'jpeg', 'gif', 'xls', 'xlsx', 'doc', 'docx', 'ppt', 'pptx', 'csv', 'svg' } @@ -28,7 +29,8 @@ class GetFileView(APIView): url_prefix = (f'{prefix}/',) def get(self, _filename): - return send_from_directory(current_app.config['UPLOAD_DIRECTORY_FULL'], _filename, as_attachment=True) + file_stream = CommonFileCRUD.get_file(_filename) + return self.send_file(file_stream, as_attachment=True, download_name=_filename) class PostFileView(APIView): @@ -43,21 +45,35 @@ def post(self): if not file: abort(400, ErrFormat.file_is_required) - extension = file.mimetype.split('/')[-1] - if file.filename == '': - filename = f'.{extension}' - else: - if extension not in file.filename: - filename = file.filename + f".{extension}" - else: - filename = file.filename - - if allowed_file(filename, current_app.config.get('ALLOWED_EXTENSIONS', ALLOWED_EXTENSIONS)): - filename = generate_new_file_name(filename) - filename = secure_filename(filename) - file.save(os.path.join( - current_app.config['UPLOAD_DIRECTORY_FULL'], filename)) - - return self.jsonify(file_name=filename) - - abort(400, 'Extension not allow') + + m_type = magic.from_buffer(file.read(2048), mime=True) + file.seek(0) + + if m_type == 'application/octet-stream': + m_type = file.mimetype + elif m_type == 'text/plain': + # https://github.com/ahupp/python-magic/issues/193 + m_type = m_type if file.mimetype == m_type else file.mimetype + + extension = MIMEExtMap.get(m_type, None) + + if extension is None: + abort(400, f"不支持的文件类型: {m_type}") + + filename = file.filename if file.filename and file.filename.endswith(extension) else file.filename + extension + + new_filename = generate_new_file_name(filename) + new_filename = secure_filename(new_filename) + file_content = file.read() + compressed_data = lz4.frame.compress(file_content) + try: + CommonFileCRUD.add_file( + origin_name=filename, + file_name=new_filename, + binary=compressed_data, + ) + + return self.jsonify(file_name=new_filename) + except Exception as e: + current_app.logger.error(e) + abort(400, ErrFormat.upload_failed.format(e)) diff --git a/acl-api/api/views/common_setting/notice_config.py b/acl-api/api/views/common_setting/notice_config.py new file mode 100644 index 0000000..e5dea63 --- /dev/null +++ b/acl-api/api/views/common_setting/notice_config.py @@ -0,0 +1,79 @@ +from flask import request, abort, current_app +from werkzeug.datastructures import MultiDict + +from api.lib.perm.auth import auth_with_app_token +from api.models.common_setting import NoticeConfig +from api.resource import APIView +from api.lib.common_setting.notice_config import NoticeConfigForm, NoticeConfigUpdateForm, NoticeConfigCRUD +from api.lib.decorator import args_required +from api.lib.common_setting.resp_format import ErrFormat + +prefix = '/notice_config' + + +class NoticeConfigView(APIView): + url_prefix = (f'{prefix}',) + + @args_required('platform') + @auth_with_app_token + def get(self): + platform = request.args.get('platform') + res = NoticeConfig.get_by(first=True, to_dict=True, platform=platform) or {} + return self.jsonify(res) + + def post(self): + form = NoticeConfigForm(MultiDict(request.json)) + if not form.validate(): + abort(400, ','.join(['{}: {}'.format(filed, ','.join(msg)) for filed, msg in form.errors.items()])) + + data = NoticeConfigCRUD.add_notice_config(**form.data) + return self.jsonify(data.to_dict()) + + +class NoticeConfigUpdateView(APIView): + url_prefix = (f'{prefix}/',) + + def put(self, _id): + form = NoticeConfigUpdateForm(MultiDict(request.json)) + if not form.validate(): + abort(400, ','.join(['{}: {}'.format(filed, ','.join(msg)) for filed, msg in form.errors.items()])) + + data = NoticeConfigCRUD.edit_notice_config(_id, **form.data) + return self.jsonify(data.to_dict()) + + +class CheckEmailServer(APIView): + url_prefix = (f'{prefix}/send_test_email',) + + def post(self): + receive_address = request.args.get('receive_address') + info = request.values.get('info', {}) + + try: + + result = NoticeConfigCRUD.test_send_email(receive_address, **info) + return self.jsonify(result=result) + except Exception as e: + current_app.logger.error('test_send_email err:') + current_app.logger.error(e) + if 'Timed Out' in str(e): + abort(400, ErrFormat.email_send_timeout) + abort(400, f"{str(e)}") + + +class NoticeConfigGetView(APIView): + method_decorators = [] + url_prefix = (f'{prefix}/all',) + + @auth_with_app_token + def get(self): + res = NoticeConfigCRUD.get_all() + return self.jsonify(res) + + +class NoticeAppBotView(APIView): + url_prefix = (f'{prefix}/app_bot',) + + def get(self): + res = NoticeConfigCRUD.get_app_bot() + return self.jsonify(res) diff --git a/acl-api/api/views/common_setting/system_language.py b/acl-api/api/views/common_setting/system_language.py new file mode 100644 index 0000000..d9defc3 --- /dev/null +++ b/acl-api/api/views/common_setting/system_language.py @@ -0,0 +1,37 @@ +import os + +from api.resource import APIView +from api.lib.perm.auth import auth_abandoned + +prefix = "/system" + + +class SystemLanguageView(APIView): + url_prefix = (f"{prefix}/language",) + + method_decorators = [] + + @auth_abandoned + def get(self): + """Get system default language + Read from environment variable SYSTEM_DEFAULT_LANGUAGE, default to Chinese if not set + """ + default_language = os.environ.get("SYSTEM_DEFAULT_LANGUAGE", "") + + return self.jsonify( + { + "language": default_language, + "language_name": self._get_language_name(default_language), + } + ) + + def _get_language_name(self, language_code): + """Return language name based on language code""" + language_mapping = { + "zh-CN": "中文(简体)", + "zh-TW": "中文(繁体)", + "en-US": "English", + "ja-JP": "日本語", + "ko-KR": "한국어", + } + return language_mapping.get(language_code, "") diff --git a/acl-api/babel.cfg b/acl-api/babel.cfg new file mode 100644 index 0000000..991e57e --- /dev/null +++ b/acl-api/babel.cfg @@ -0,0 +1 @@ +[python: api/**.py] diff --git a/acl-api/requirements.txt b/acl-api/requirements.txt index 31b6886..8fac696 100644 --- a/acl-api/requirements.txt +++ b/acl-api/requirements.txt @@ -78,3 +78,5 @@ vine==1.3.0 Werkzeug==0.15.5 WTForms==3.0.0 zipp==3.16.0 +python-magic==0.4.27 +python-redis-lock==4.0.0 diff --git a/acl-api/settings.example.py b/acl-api/settings.example.py index 3db205d..606408a 100644 --- a/acl-api/settings.example.py +++ b/acl-api/settings.example.py @@ -66,26 +66,76 @@ } } -# # SSO -CAS_SERVER = "http://sso.xxx.com" -CAS_VALIDATE_SERVER = "http://sso.xxx.com" -CAS_LOGIN_ROUTE = "/cas/login" -CAS_LOGOUT_ROUTE = "/cas/logout" -CAS_VALIDATE_ROUTE = "/cas/serviceValidate" -CAS_AFTER_LOGIN = "/" -DEFAULT_SERVICE = "http://127.0.0.1:8000" - -# # ldap -AUTH_WITH_LDAP = False -LDAP_SERVER = '' -LDAP_DOMAIN = '' +# =============================== Authentication =========================================================== + +# # CAS +CAS = dict( + enabled=False, + cas_server='https://{your-CASServer-hostname}', + cas_validate_server='https://{your-CASServer-hostname}', + cas_login_route='/cas/built-in/cas/login', + cas_logout_route='/cas/built-in/cas/logout', + cas_validate_route='/cas/built-in/cas/serviceValidate', + cas_after_login='/', + cas_user_map={ + 'username': {'tag': 'cas:user'}, + 'nickname': {'tag': 'cas:attribute', 'attrs': {'name': 'displayName'}}, + 'email': {'tag': 'cas:attribute', 'attrs': {'name': 'email'}}, + 'mobile': {'tag': 'cas:attribute', 'attrs': {'name': 'phone'}}, + 'avatar': {'tag': 'cas:attribute', 'attrs': {'name': 'avatar'}}, + } +) + +# # OAuth2.0 +OAUTH2 = dict( + enabled=False, + client_id='', + client_secret='', + authorize_url='https://{your-OAuth2Server-hostname}/login/oauth/authorize', + token_url='https://{your-OAuth2Server-hostname}/api/login/oauth/access_token', + scopes=['profile', 'email'], + user_info={ + 'url': 'https://{your-OAuth2Server-hostname}/api/userinfo', + 'email': 'email', + 'username': 'name', + 'avatar': 'picture' + }, + after_login='/' +) + +# # OIDC +OIDC = dict( + enabled=False, + client_id='', + client_secret='', + authorize_url='https://{your-OIDCServer-hostname}/login/oauth/authorize', + token_url='https://{your-OIDCServer-hostname}/api/login/oauth/access_token', + scopes=['openid', 'profile', 'email'], + user_info={ + 'url': 'https://{your-OIDCServer-hostname}/api/userinfo', + 'email': 'email', + 'username': 'name', + 'avatar': 'picture' + }, + after_login='/' +) + +# # LDAP +LDAP = dict( + enabled=False, + ldap_server='', + ldap_domain='', + ldap_user_dn='cn={},ou=users,dc=xxx,dc=com' +) +# ========================================================================================================== + # # pagination DEFAULT_PAGE_COUNT = 50 # # permission WHITE_LIST = ["127.0.0.1"] -USE_ACL = False +USE_ACL = True # # elastic search ES_HOST = '127.0.0.1' diff --git a/acl-ui/src/assets/icons/ops-move-icon.svg b/acl-ui/src/assets/icons/ops-move-icon.svg new file mode 100644 index 0000000..c10fc54 --- /dev/null +++ b/acl-ui/src/assets/icons/ops-move-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/acl-ui/src/config/setting.js b/acl-ui/src/config/setting.js index 4b7b85b..47a7f8b 100644 --- a/acl-ui/src/config/setting.js +++ b/acl-ui/src/config/setting.js @@ -15,7 +15,7 @@ */ export default { - useSSO: false, + useSSO: true, ssoLoginUrl: '/api/sso/login', primaryColor: '#1890ff', // primary color of ant design navTheme: 'dark', // theme for nav menu diff --git a/acl-ui/src/core/icons.js b/acl-ui/src/core/icons.js index 76bf5d8..cbea4d7 100644 --- a/acl-ui/src/core/icons.js +++ b/acl-ui/src/core/icons.js @@ -6,6 +6,7 @@ * 自定义图标加载表 * 所有图标均从这里加载,方便管理 */ - +import ops_move_icon from '@/assets/icons/ops-move-icon.svg?inline' export { + ops_move_icon } diff --git a/acl-ui/src/modules/acl/views/resources.vue b/acl-ui/src/modules/acl/views/resources.vue index f888088..818ca14 100644 --- a/acl-ui/src/modules/acl/views/resources.vue +++ b/acl-ui/src/modules/acl/views/resources.vue @@ -36,7 +36,7 @@ $refs.resourceBatchPerm.open(currentType.id) } " - >便捷授权便捷授权 - +