From a852eb7e4738b215e8e36684a502d5dab3237d85 Mon Sep 17 00:00:00 2001 From: neronkl <49228807+neronkl@users.noreply.github.com> Date: Mon, 13 Nov 2023 20:25:15 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E9=85=8D?= =?UTF-8?q?=E7=BD=AE-=20=E8=B4=A6=E6=88=B7=E6=9C=89=E6=95=88=E6=9C=9F=20(#?= =?UTF-8?q?1361)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/bk-user/bkuser/apis/web/tenant/views.py | 2 + .../apis/web/tenant_setting/serializers.py | 32 +++- .../bkuser/apis/web/tenant_setting/urls.py | 5 + .../bkuser/apis/web/tenant_setting/views.py | 67 ++++++- src/bk-user/bkuser/apps/tenant/constants.py | 92 ++++++++++ .../migrations/0003_auto_20231113_2017.py | 38 ++++ src/bk-user/bkuser/apps/tenant/models.py | 41 ++--- src/bk-user/bkuser/apps/tenant/notifier.py | 164 ++++++++++++++++++ src/bk-user/bkuser/apps/tenant/tasks.py | 101 +++++++++++ .../bkuser/biz/data_source_organization.py | 13 +- src/bk-user/bkuser/biz/tenant.py | 12 +- src/bk-user/bkuser/settings.py | 12 +- 12 files changed, 550 insertions(+), 29 deletions(-) create mode 100644 src/bk-user/bkuser/apps/tenant/migrations/0003_auto_20231113_2017.py create mode 100644 src/bk-user/bkuser/apps/tenant/notifier.py create mode 100644 src/bk-user/bkuser/apps/tenant/tasks.py diff --git a/src/bk-user/bkuser/apis/web/tenant/views.py b/src/bk-user/bkuser/apis/web/tenant/views.py index 5d3635f65..c11c5335d 100644 --- a/src/bk-user/bkuser/apis/web/tenant/views.py +++ b/src/bk-user/bkuser/apis/web/tenant/views.py @@ -99,6 +99,8 @@ def post(self, request, *args, **kwargs): ] # 本地数据源密码初始化配置 config = PasswordInitialConfig(**data["password_initial_config"]) + + # 创建租户和租户管理员 tenant_id = TenantHandler.create_with_managers(tenant_info, managers, config) return Response(TenantCreateOutputSLZ(instance={"id": tenant_id}).data) diff --git a/src/bk-user/bkuser/apis/web/tenant_setting/serializers.py b/src/bk-user/bkuser/apis/web/tenant_setting/serializers.py index 59e381d3a..cbeed75c0 100644 --- a/src/bk-user/bkuser/apis/web/tenant_setting/serializers.py +++ b/src/bk-user/bkuser/apis/web/tenant_setting/serializers.py @@ -16,7 +16,7 @@ from rest_framework import serializers from rest_framework.exceptions import ValidationError -from bkuser.apps.tenant.constants import UserFieldDataType +from bkuser.apps.tenant.constants import NotificationMethod, NotificationScene, UserFieldDataType from bkuser.apps.tenant.data_models import TenantUserCustomFieldOptions from bkuser.apps.tenant.models import TenantUserCustomField, UserBuiltinField @@ -163,3 +163,33 @@ def validate(self, attrs): _validate_multi_enum_default(default, opt_ids) return attrs + + +class NotificationTemplatesInputSLZ(serializers.Serializer): + method = serializers.ChoiceField(help_text="通知方式", choices=NotificationMethod.get_choices()) + scene = serializers.ChoiceField(help_text="通知场景", choices=NotificationScene.get_choices()) + title = serializers.CharField(help_text="通知标题", allow_null=True) + sender = serializers.CharField(help_text="发送人") + content = serializers.CharField(help_text="通知内容") + content_html = serializers.CharField(help_text="通知内容,页面展示使用") + + +class TenantUserValidityPeriodConfigInputSLZ(serializers.Serializer): + enabled = serializers.BooleanField(help_text="是否启用账户有效期") + validity_period = serializers.IntegerField(help_text="账户有效期,单位:天") + remind_before_expire = serializers.ListField( + help_text="临过期提醒时间", + child=serializers.IntegerField(min_value=1), + ) + enabled_notification_methods = serializers.ListField( + help_text="通知方式", + child=serializers.ChoiceField(choices=NotificationMethod.get_choices()), + allow_empty=False, + ) + notification_templates = serializers.ListField( + help_text="通知模板", child=NotificationTemplatesInputSLZ(), allow_empty=False + ) + + +class TenantUserValidityPeriodConfigOutputSLZ(TenantUserValidityPeriodConfigInputSLZ): + pass diff --git a/src/bk-user/bkuser/apis/web/tenant_setting/urls.py b/src/bk-user/bkuser/apis/web/tenant_setting/urls.py index 50275f541..d585b2bd0 100644 --- a/src/bk-user/bkuser/apis/web/tenant_setting/urls.py +++ b/src/bk-user/bkuser/apis/web/tenant_setting/urls.py @@ -20,4 +20,9 @@ views.TenantUserCustomFieldUpdateDeleteApi.as_view(), name="tenant_setting_custom_fields.update_delete", ), + path( + "settings/tenant-user-validity-period/", + views.TenantUserValidityPeriodConfigRetrieveUpdateApi.as_view(), + name="tenant_user_validity_period_config.retrieve_update", + ), ] diff --git a/src/bk-user/bkuser/apis/web/tenant_setting/views.py b/src/bk-user/bkuser/apis/web/tenant_setting/views.py index 35f31d635..8be22b64c 100644 --- a/src/bk-user/bkuser/apis/web/tenant_setting/views.py +++ b/src/bk-user/bkuser/apis/web/tenant_setting/views.py @@ -10,6 +10,7 @@ """ from drf_yasg.utils import swagger_auto_schema from rest_framework import generics, status +from rest_framework.generics import get_object_or_404 from rest_framework.response import Response from bkuser.apis.web.mixins import CurrentUserTenantMixin @@ -18,9 +19,17 @@ TenantUserCustomFieldCreateOutputSLZ, TenantUserCustomFieldUpdateInputSLZ, TenantUserFieldOutputSLZ, + TenantUserValidityPeriodConfigInputSLZ, + TenantUserValidityPeriodConfigOutputSLZ, ) -from bkuser.apps.tenant.models import TenantUserCustomField, UserBuiltinField -from bkuser.common.views import ExcludePutAPIViewMixin +from bkuser.apps.tenant.models import ( + TenantManager, + TenantUserCustomField, + TenantUserValidityPeriodConfig, + UserBuiltinField, +) +from bkuser.common.error_codes import error_codes +from bkuser.common.views import ExcludePatchAPIViewMixin, ExcludePutAPIViewMixin class TenantUserFieldListApi(CurrentUserTenantMixin, generics.ListAPIView): @@ -82,8 +91,7 @@ def put(self, request, *args, **kwargs): tenant_id = self.get_current_tenant_id() slz = TenantUserCustomFieldUpdateInputSLZ( - data=request.data, - context={"tenant_id": tenant_id, "current_custom_field_id": kwargs["id"]}, + data=request.data, context={"tenant_id": tenant_id, "current_custom_field_id": kwargs["id"]} ) slz.is_valid(raise_exception=True) data = slz.validated_data @@ -104,3 +112,54 @@ def put(self, request, *args, **kwargs): ) def delete(self, request, *args, **kwargs): return self.destroy(request, *args, **kwargs) + + +class TenantUserValidityPeriodConfigRetrieveUpdateApi( + ExcludePatchAPIViewMixin, CurrentUserTenantMixin, generics.RetrieveUpdateAPIView +): + def get_object(self): + queryset = TenantUserValidityPeriodConfig.objects.all() + filter_kwargs = {"tenant_id": self.get_current_tenant_id()} + return get_object_or_404(queryset, **filter_kwargs) + + @swagger_auto_schema( + tags=["tenant-setting"], + operation_description="当前租户的账户有效期配置", + responses={ + status.HTTP_200_OK: TenantUserValidityPeriodConfigOutputSLZ(), + }, + ) + def get(self, request, *args, **kwargs): + instance = self.get_object() + return Response(TenantUserValidityPeriodConfigOutputSLZ(instance=instance).data) + + @swagger_auto_schema( + tags=["tenant-setting"], + operation_description="更新当前租户的账户有效期配置", + request_body=TenantUserValidityPeriodConfigInputSLZ(), + responses={ + status.HTTP_204_NO_CONTENT: "", + }, + ) + def put(self, request, *args, **kwargs): + instance = self.get_object() + + # TODO (su) 权限调整为 perm_class 当前租户的管理才可做更新操作 + operator = request.user.username + if not TenantManager.objects.filter(tenant_id=instance.tenant_id, tenant_user_id=operator).exists(): + raise error_codes.NO_PERMISSION + + slz = TenantUserValidityPeriodConfigInputSLZ(data=request.data) + slz.is_valid(raise_exception=True) + data = slz.validated_data + + instance.enabled = data["enabled"] + instance.validity_period = data["validity_period"] + instance.remind_before_expire = data["remind_before_expire"] + instance.enabled_notification_methods = data["enabled_notification_methods"] + instance.notification_templates = data["notification_templates"] + instance.updater = operator + + instance.save() + + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/src/bk-user/bkuser/apps/tenant/constants.py b/src/bk-user/bkuser/apps/tenant/constants.py index 5bcd3347f..8cb4276bf 100644 --- a/src/bk-user/bkuser/apps/tenant/constants.py +++ b/src/bk-user/bkuser/apps/tenant/constants.py @@ -32,3 +32,95 @@ class UserFieldDataType(str, StructuredEnum): NUMBER = EnumField("number", label=_("数字")) ENUM = EnumField("enum", label=_("枚举")) MULTI_ENUM = EnumField("multi_enum", label=_("多选枚举")) + + +class NotificationMethod(str, StructuredEnum): + """通知方式""" + + EMAIL = EnumField("email", label=_("邮件通知")) + SMS = EnumField("sms", label=_("短信通知")) + + +class NotificationScene(str, StructuredEnum): + """通知场景""" + + TENANT_USER_EXPIRING = EnumField("tenant_user_expiring", label=_("租户用户即将过期")) + TENANT_USER_EXPIRED = EnumField("tenant_user_expired", label=_("租户用户已过期")) + + +DEFAULT_TENANT_USER_VALIDITY_PERIOD_CONFIG = { + "enabled": True, + "validity_period": 365, + "remind_before_expire": [7], + "enabled_notification_methods": [NotificationMethod.EMAIL], + "notification_templates": [ + { + "method": NotificationMethod.EMAIL, + "scene": NotificationScene.TENANT_USER_EXPIRING, + "title": "蓝鲸智云 - 账号即将到期提醒!", + "sender": "蓝鲸智云", + "content": ( + "{{ username }}, 您好:\n " + + "您的蓝鲸智云平台账号将于 {{ remind_before_expire_days }} 天后到期。" + + "为避免影响使用,请尽快联系平台管理员进行续期。\n " + + "此邮件为系统自动发送,请勿回复。\n " + ), + "content_html": ( + "

{{ username }}, 您好:

" + + "

您的蓝鲸智云平台账号将于 {{ remind_before_expire_days }} 天后到期。" + + "为避免影响使用,请尽快联系平台管理员进行续期。

" + + "

此邮件为系统自动发送,请勿回复。

" + ), + }, + { + "method": NotificationMethod.EMAIL, + "scene": NotificationScene.TENANT_USER_EXPIRED, + "title": "蓝鲸智云 - 账号到期提醒!", + "sender": "蓝鲸智云", + "content": ( + "{{ username }},您好:\n " + + "您的蓝鲸智云平台账号已过期。为避免影响使用,请尽快联系平台管理员进行续期。\n " # noqa: E501 + + "该邮件为系统自动发送,请勿回复。" # noqa: E501 + ), + "content_html": ( + "

{{ username }},您好:

" + + "

您的蓝鲸智云平台账号已过期,如需继续使用,请尽快联系平台管理员进行续期。

" # noqa: E501 + + "

此邮件为系统自动发送,请勿回复。

" + ), + }, + { + "method": NotificationMethod.SMS, + "scene": NotificationScene.TENANT_USER_EXPIRING, + "title": None, + "sender": "蓝鲸智云", + "content": ( + "{{ username }},您好:\n " + + "您的蓝鲸智云平台账号将于 {{ remind_before_expire_days }} 天后到期。" + + "为避免影响使用,请尽快联系平台管理员进行续期。\n " + + "该短信为系统自动发送,请勿回复。" + ), + "content_html": ( + "

{{ username }},您好:

" + + "

您的蓝鲸智云平台账号将于 {{ remind_before_expire_days }} 天后到期。" + + "为避免影响使用,请尽快联系平台管理员进行续期。

" + + "

该短信为系统自动发送,请勿回复。

" + ), + }, + { + "method": NotificationMethod.SMS, + "scene": NotificationScene.TENANT_USER_EXPIRED, + "title": None, + "sender": "蓝鲸智云", + "content": ( + "{{ username }}您好:\n " + + "您的蓝鲸智云平台账号已过期,如需继续使用,请尽快联系平台管理员进行续期。\n " # noqa: E501 + + "该短信为系统自动发送,请勿回复。" # noqa: E501 + ), + "content_html": ( + "

{{ username }}您好:

" + + "

您的蓝鲸智云平台账号已过期,如需继续使用,请尽快联系平台管理员进行续期。

" # noqa: E501 + + "

该短信为系统自动发送,请勿回复。

" + ), + }, + ], +} diff --git a/src/bk-user/bkuser/apps/tenant/migrations/0003_auto_20231113_2017.py b/src/bk-user/bkuser/apps/tenant/migrations/0003_auto_20231113_2017.py new file mode 100644 index 000000000..011a8acbd --- /dev/null +++ b/src/bk-user/bkuser/apps/tenant/migrations/0003_auto_20231113_2017.py @@ -0,0 +1,38 @@ +# Generated by Django 3.2.20 on 2023-11-13 12:17 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenant', '0002_init_builtin_user_fields'), + ] + + operations = [ + migrations.AlterField( + model_name='tenantuser', + name='wx_openid', + field=models.CharField(blank=True, default='', max_length=64, null=True, verbose_name='微信公众号 用户OpenID'), + ), + migrations.CreateModel( + name='TenantUserValidityPeriodConfig', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('creator', models.CharField(blank=True, max_length=128, null=True)), + ('updater', models.CharField(blank=True, max_length=128, null=True)), + ('enabled', models.BooleanField(default=True, verbose_name='是否启用账户有效期')), + ('validity_period', models.IntegerField(default=-1, verbose_name='有效期(单位:天)')), + ('remind_before_expire', models.JSONField(default=list, verbose_name='临X天过期发送提醒(单位:天)')), + ('enabled_notification_methods', models.JSONField(default=list, verbose_name='通知方式')), + ('notification_templates', models.JSONField(default=list, verbose_name='通知模板')), + ('tenant', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='tenant.tenant')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/src/bk-user/bkuser/apps/tenant/models.py b/src/bk-user/bkuser/apps/tenant/models.py index f99ada30d..4bca8cc0e 100644 --- a/src/bk-user/bkuser/apps/tenant/models.py +++ b/src/bk-user/bkuser/apps/tenant/models.py @@ -12,13 +12,11 @@ from django.db import models from bkuser.apps.data_source.models import DataSource, DataSourceDepartment, DataSourceUser -from bkuser.apps.tenant.constants import TenantFeatureFlag, UserFieldDataType +from bkuser.apps.tenant.constants import TIME_ZONE_CHOICES, TenantFeatureFlag, UserFieldDataType from bkuser.common.constants import PERMANENT_TIME, BkLanguageEnum -from bkuser.common.models import TimestampedModel +from bkuser.common.models import AuditedModel, TimestampedModel from bkuser.common.time import datetime_to_display -from .constants import TIME_ZONE_CHOICES - class Tenant(TimestampedModel): id = models.CharField("租户唯一标识", primary_key=True, max_length=128) @@ -56,7 +54,7 @@ class TenantUser(TimestampedModel): # wx_userid/wx_openid 兼容旧版本迁移 wx_userid = models.CharField("微信ID", null=True, blank=True, default="", max_length=64) - wx_openid = models.CharField("微信公众号OpenID", null=True, blank=True, default="", max_length=64) + wx_openid = models.CharField("微信公众号 用户OpenID", null=True, blank=True, default="", max_length=64) # 账号有效期相关 account_expired_at = models.DateTimeField("账号过期时间", null=True, blank=True, default=PERMANENT_TIME) @@ -83,6 +81,14 @@ class Meta: def account_expired_at_display(self) -> str: return datetime_to_display(self.account_expired_at) + @property + def real_phone(self) -> str: + return self.data_source_user.phone if self.is_inherited_phone else self.custom_phone + + @property + def real_email(self) -> str: + return self.data_source_user.email if self.is_inherited_email else self.custom_email + class TenantDepartment(TimestampedModel): """ @@ -143,21 +149,16 @@ class Meta: ] -# # TODO: 是否直接定义 TenantCommonConfig 表,AccountValidityPeriod是一个JSON字段? -# class AccountValidityPeriodConfig: -# """账号时效配置""" -# -# tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, db_index=True, unique=True) -# -# enabled = models.BooleanField("是否启用", default=True) -# # TODO: 定义枚举,设置默认值为永久 -# validity_period_seconds = models.IntegerField("有效期(单位:秒)", default=-1) -# # TODO: 定义枚举,设置默认值为7天 -# reminder_period_days = models.IntegerField("提醒周期(单位:天)", default=7) -# # TODO: 定义枚举,同时需要考虑到与企业ESB配置的支持的通知方式有关,是否定义字段? -# notification_method = models.CharField("通知方式", max_length=32, default="email") -# # TODO: 需要考虑不同通知方式,可能无法使用相同模板,或者其他设计方式 -# notification_content_template = models.TextField("通知模板", default="") +class TenantUserValidityPeriodConfig(AuditedModel): + """账号有效期-配置""" + + tenant = models.OneToOneField(Tenant, on_delete=models.CASCADE, db_index=True, unique=True) + + enabled = models.BooleanField("是否启用账户有效期", default=True) + validity_period = models.IntegerField("有效期(单位:天)", default=-1) + remind_before_expire = models.JSONField("临X天过期发送提醒(单位:天)", default=list) + enabled_notification_methods = models.JSONField("通知方式", default=list) + notification_templates = models.JSONField("通知模板", default=list) # class TenantUserSocialAccountRelation(TimestampedModel): diff --git a/src/bk-user/bkuser/apps/tenant/notifier.py b/src/bk-user/bkuser/apps/tenant/notifier.py new file mode 100644 index 000000000..1064149e4 --- /dev/null +++ b/src/bk-user/bkuser/apps/tenant/notifier.py @@ -0,0 +1,164 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. +""" +import logging +from typing import Dict, List, Optional + +from django.template import Context, Template +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ +from pydantic import BaseModel, model_validator + +from bkuser.apps.tenant.constants import NotificationMethod, NotificationScene +from bkuser.apps.tenant.models import TenantUser, TenantUserValidityPeriodConfig +from bkuser.component import cmsi + +logger = logging.getLogger(__name__) + + +class NotificationTemplate(BaseModel): + """通知模板""" + + # 通知方式 如短信,邮件 + method: NotificationMethod + # 通知场景 如将过期,已过期 + scene: NotificationScene + # 模板标题 + title: Optional[str] = None + # 模板发送方 + sender: str + # 模板内容(text)格式 + content: str + # 模板内容(html)格式 + content_html: str + + @model_validator(mode="after") + def validate_attrs(self) -> "NotificationTemplate": + if self.method == NotificationMethod.EMAIL and not self.title: + raise ValueError(_("邮件通知模板需要提供标题")) + + return self + + +class ValidityPeriodNotificationTmplContextGenerator: + """生成通知模板使用的上下文""" + + def __init__(self, user: TenantUser, scene: NotificationScene): + self.user = user + self.scene = scene + + def gen(self) -> Dict[str, str]: + """生成通知模板使用的上下文 + + 注:为保证模板渲染准确性,value 值类型需为 str + """ + if self.scene == NotificationScene.TENANT_USER_EXPIRING: + return self._gen_tenant_user_expiring_ctx() + if self.scene == NotificationScene.TENANT_USER_EXPIRED: + return self._gen_tenant_user_expired_ctx() + return self._gen_base_ctx() + + def _gen_base_ctx(self) -> Dict[str, str]: + """获取基础信息""" + return {"username": self.user.data_source_user.username} + + def _gen_tenant_user_expiring_ctx(self) -> Dict[str, str]: + """账号有效期-临期通知渲染参数""" + remind_before_expire_day = self.user.account_expired_at - timezone.now() + return { + "remind_before_expire_days": str(remind_before_expire_day.days + 1), + **self._gen_base_ctx(), + } + + def _gen_tenant_user_expired_ctx(self) -> Dict[str, str]: + """账号有效期-过期通知渲染参数""" + return self._gen_base_ctx() + + +class TenantUserValidityPeriodNotifier: + """租户用户用户通知器,支持批量像用户发送某类信息""" + + def __init__(self, tenant_id: str, scene: NotificationScene): + self.tenant_id = tenant_id + self.scene = scene + + self.templates = self._get_templates_with_scene(scene) + + def send(self, users: List[TenantUser]) -> None: + """根据配置,发送对应的通知信息""" + for u in users: + try: + self._send_notifications(u) + # TODO 细化异常处理 + except Exception: # noqa: PERF203 + logger.exception( + "send notification failed, tenant: %s, scene: %s, tenant_user: %s", + self.tenant_id, + self.scene, + u.id, + ) + + def _get_templates_with_scene(self, scene: NotificationScene) -> List[NotificationTemplate]: + """根据场景以及插件配置中设置的通知方式,获取需要发送通知的模板""" + + if scene not in [NotificationScene.TENANT_USER_EXPIRED, NotificationScene.TENANT_USER_EXPIRING]: + raise ValueError(_("通知场景 {} 未被支持".format(scene))) + + # 获取通知配置 + cfg = TenantUserValidityPeriodConfig.objects.get(tenant_id=self.tenant_id) + + # 返回场景匹配,且被声明启用的模板列表 + return [ + NotificationTemplate(**tmpl) + for tmpl in cfg.notification_templates + if tmpl["scene"] == scene and tmpl["method"] in cfg.enabled_notification_methods + ] + + def _send_notifications(self, user: TenantUser): + """根据配置的通知模板,逐个用户发送通知""" + for tmpl in self.templates: + if tmpl.method == NotificationMethod.EMAIL: + self._send_email(user, tmpl) + elif tmpl.method == NotificationMethod.SMS: + self._send_sms(user, tmpl) + + def _send_email(self, user: TenantUser, tmpl: NotificationTemplate): + # 根据继承与否,获取真实邮箱 + logger.info( + "send email to user %s, email %s, scene %s, title: %s", + user.data_source_user.username, + user.real_email, + tmpl.scene, + tmpl.title, + ) + email = user.real_email + if not email: + logger.info("user<%s> have no email, not to send_email", user.data_source_user.username) + return + + content = self._render_tmpl(user, tmpl.content_html) + cmsi.send_mail([email], tmpl.sender, tmpl.title, content) # type: ignore + + def _send_sms(self, user: TenantUser, tmpl: NotificationTemplate): + logger.info( + "send sms to user %s, phone %s, scene %s", user.data_source_user.username, user.real_phone, tmpl.scene + ) + # 根据继承与否,获取真实手机号 + phone = user.real_phone + if not phone: + logger.info("user<%s> have no phone number, not to send_sms", user.data_source_user.username) + return + + content = self._render_tmpl(user, tmpl.content) + cmsi.send_sms([phone], content) + + def _render_tmpl(self, user: TenantUser, content: str) -> str: + ctx = ValidityPeriodNotificationTmplContextGenerator(user=user, scene=self.scene).gen() + return Template(content).render(Context(ctx)) diff --git a/src/bk-user/bkuser/apps/tenant/tasks.py b/src/bk-user/bkuser/apps/tenant/tasks.py new file mode 100644 index 000000000..2dd34d2a2 --- /dev/null +++ b/src/bk-user/bkuser/apps/tenant/tasks.py @@ -0,0 +1,101 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. +""" +import datetime +import logging +from typing import List + +from django.utils import timezone + +from bkuser.apps.tenant.constants import NotificationScene +from bkuser.apps.tenant.models import Tenant, TenantUser, TenantUserValidityPeriodConfig +from bkuser.apps.tenant.notifier import TenantUserValidityPeriodNotifier +from bkuser.celery import app +from bkuser.common.task import BaseTask + +logger = logging.getLogger(__name__) + + +@app.task(base=BaseTask, ignore_result=True) +def send_notifications(tenant_id: str, scene: NotificationScene, tenant_user_ids: List[str]): + # TODO: 后续考虑租户、用户状态等,冻结等非正常状态用户不通知 + users = TenantUser.objects.filter(id__in=tenant_user_ids) + logger.info( + "going to send notification for users. user_count=%s tenant=%s, scene=%s", len(users), tenant_id, scene + ) + try: + TenantUserValidityPeriodNotifier(tenant_id=tenant_id, scene=scene).send(users) + except Exception: + logger.exception("send notification failed, please check!") + + +@app.task(base=BaseTask, ignore_result=True) +def notify_expiring_tenant_user(): + """扫描全部租户用户,做即将过期通知""" + logger.info("[celery] receive period task:send_tenant_user_expiring_notification") + now = timezone.now() + + # 获取账号有效期-临期配置 + tenant_remind_before_expire_list = TenantUserValidityPeriodConfig.objects.all().values( + "tenant_id", "remind_before_expire" + ) + + for item in tenant_remind_before_expire_list: + tenant_id, remind_before_expire = item["tenant_id"], item["remind_before_expire"] + + tenant_users = TenantUser.objects.filter(account_expired_at__date__gt=now.date(), tenant_id=tenant_id) + + # 临1/7/15天过期 条件设置, 每个租户的设置都不一样 + account_expired_date_list = [] + for remain_days in remind_before_expire: + account_expired_at = now + datetime.timedelta(days=int(remain_days)) + account_expired_date_list.append(account_expired_at.date()) + + should_notify_user_ids = list( + tenant_users.filter(account_expired_at__date__in=account_expired_date_list).values_list("id", flat=True) + ) + # 发送通知 + logger.info("going to notify expiring users in tenant{%s}, count: %s", tenant_id, len(should_notify_user_ids)) + + if not should_notify_user_ids: + continue + + send_notifications.delay( + tenant_id=tenant_id, scene=NotificationScene.TENANT_USER_EXPIRING, tenant_user_ids=should_notify_user_ids + ) + + +@app.task(base=BaseTask, ignore_result=True) +def notify_expired_tenant_user(): + """扫描全部租户用户,做过期通知""" + logger.info("[celery] receive period task:send_tenant_user_expired_notification") + + # 今日过期, 当前时间转换为实际时区的时间 + now = timezone.now() + + # 获取 租户-过期用户 + tenant_ids = Tenant.objects.all().values_list("id", flat=True) + + for tenant_id in tenant_ids: + # 发送过期通知 + should_notify_user_ids = list( + TenantUser.objects.filter( + account_expired_at__date=now.date(), + tenant_id=tenant_id, + ).values_list("id", flat=True) + ) + + logger.info("going to notify expired users in tenant{%s}, count: %s", tenant_id, len(should_notify_user_ids)) + if not should_notify_user_ids: + continue + + send_notifications.delay( + tenant_id=tenant_id, scene=NotificationScene.TENANT_USER_EXPIRED, tenant_user_ids=should_notify_user_ids + ) diff --git a/src/bk-user/bkuser/biz/data_source_organization.py b/src/bk-user/bkuser/biz/data_source_organization.py index f7a4691bf..9c4e10fd5 100644 --- a/src/bk-user/bkuser/biz/data_source_organization.py +++ b/src/bk-user/bkuser/biz/data_source_organization.py @@ -8,10 +8,12 @@ an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. """ +import datetime from collections import defaultdict from typing import Dict, List from django.db import transaction +from django.utils import timezone from pydantic import BaseModel from bkuser.apps.data_source.models import ( @@ -21,7 +23,7 @@ DataSourceUser, DataSourceUserLeaderRelation, ) -from bkuser.apps.tenant.models import Tenant, TenantUser +from bkuser.apps.tenant.models import Tenant, TenantUser, TenantUserValidityPeriodConfig from bkuser.utils.uuid import generate_uuid @@ -102,14 +104,21 @@ def create_user( # 查询关联的租户 tenant = Tenant.objects.get(id=data_source.owner_tenant_id) + # 创建租户用户 - TenantUser.objects.create( + tenant_user = TenantUser( data_source_user=user, tenant=tenant, data_source=data_source, id=generate_uuid(), ) + # 根据配置初始化账号有效期 + cfg = TenantUserValidityPeriodConfig.objects.get(tenant_id=tenant.id) + if cfg.enabled and cfg.validity_period > 0: + tenant_user.account_expired_at = timezone.now() + datetime.timedelta(days=cfg.validity_period) + # 入库 + tenant_user.save() return user.id @staticmethod diff --git a/src/bk-user/bkuser/biz/tenant.py b/src/bk-user/bkuser/biz/tenant.py index 83da08107..af50a85dc 100644 --- a/src/bk-user/bkuser/biz/tenant.py +++ b/src/bk-user/bkuser/biz/tenant.py @@ -18,7 +18,14 @@ from pydantic import BaseModel from bkuser.apps.data_source.models import DataSourceDepartmentRelation, DataSourceUser -from bkuser.apps.tenant.models import Tenant, TenantDepartment, TenantManager, TenantUser +from bkuser.apps.tenant.constants import DEFAULT_TENANT_USER_VALIDITY_PERIOD_CONFIG +from bkuser.apps.tenant.models import ( + Tenant, + TenantDepartment, + TenantManager, + TenantUser, + TenantUserValidityPeriodConfig, +) from bkuser.biz.data_source import ( DataSourceDepartmentHandler, DataSourceHandler, @@ -290,6 +297,9 @@ def create_with_managers( # 创建租户本身 tenant = Tenant.objects.create(**tenant_info.model_dump()) + # 创建租户完成后,初始化账号有效期设置 + TenantUserValidityPeriodConfig.objects.create(tenant=tenant, **DEFAULT_TENANT_USER_VALIDITY_PERIOD_CONFIG) + # 创建本地数据源 data_source = DataSourceHandler.create_local_data_source_with_merge_config( _("{}-本地数据源").format(tenant_info.name), tenant.id, password_initial_config diff --git a/src/bk-user/bkuser/settings.py b/src/bk-user/bkuser/settings.py index 93789164d..1077573f5 100644 --- a/src/bk-user/bkuser/settings.py +++ b/src/bk-user/bkuser/settings.py @@ -16,6 +16,7 @@ import environ import urllib3 +from celery.schedules import crontab from django.utils.encoding import force_bytes # environ @@ -234,7 +235,16 @@ # CELERY 配置,申明任务的文件路径,即包含有 @task 装饰器的函数文件 # CELERY_IMPORTS = [] # 内置的周期任务 -# CELERYBEAT_SCHEDULE = {} +CELERYBEAT_SCHEDULE = { + "periodic_notify_expiring_tenant_users": { + "task": "bkuser.apps.tenant.tasks.notify_expiring_tenant_user", + "schedule": crontab(minute="0", hour="10"), # 每天10时执行 + }, + "periodic_notify_expired_tenant_users": { + "task": "bkuser.apps.tenant.tasks.notify_expired_tenant_user", + "schedule": crontab(minute="0", hour="10"), # 每天10时执行 + }, +} # Celery 消息队列配置 CELERY_BROKER_URL = env.str("BK_BROKER_URL", default="") From 049be01ace1b0fa88025563d28f6234fd1837f90 Mon Sep 17 00:00:00 2001 From: schnee Date: Mon, 13 Nov 2023 20:42:47 +0800 Subject: [PATCH 2/2] feat: rebuild api permission control (#1378) --- .../bkuser/apis/web/basic/serializers.py | 3 + src/bk-user/bkuser/apis/web/basic/views.py | 7 +- .../bkuser/apis/web/data_source/views.py | 14 ++ .../web/data_source_organization/views.py | 7 + .../bkuser/apis/web/organization/views.py | 24 ++-- .../bkuser/apis/web/personal_center/views.py | 53 ++----- src/bk-user/bkuser/apis/web/tenant/views.py | 7 +- .../bkuser/apis/web/tenant_setting/views.py | 34 ++--- .../bkuser/apps/permission/__init__.py | 10 ++ src/bk-user/bkuser/apps/permission/apps.py | 16 +++ .../bkuser/apps/permission/constants.py | 36 +++++ .../bkuser/apps/permission/permissions.py | 134 ++++++++++++++++++ src/bk-user/bkuser/settings.py | 1 + src/bk-user/pyproject.toml | 2 +- src/bk-user/tests/conftest.py | 2 +- src/bk-user/tests/test_utils/auth.py | 18 ++- 16 files changed, 291 insertions(+), 77 deletions(-) create mode 100644 src/bk-user/bkuser/apps/permission/__init__.py create mode 100644 src/bk-user/bkuser/apps/permission/apps.py create mode 100644 src/bk-user/bkuser/apps/permission/constants.py create mode 100644 src/bk-user/bkuser/apps/permission/permissions.py diff --git a/src/bk-user/bkuser/apis/web/basic/serializers.py b/src/bk-user/bkuser/apis/web/basic/serializers.py index d928d6e29..b2a6ad5ab 100644 --- a/src/bk-user/bkuser/apis/web/basic/serializers.py +++ b/src/bk-user/bkuser/apis/web/basic/serializers.py @@ -10,7 +10,10 @@ """ from rest_framework import serializers +from bkuser.apps.permission.constants import UserRole + class CurrentUserRetrieveOutputSLZ(serializers.Serializer): username = serializers.CharField(help_text="用户名") tenant_id = serializers.CharField(help_text="租户 ID") + role = serializers.ChoiceField(help_text="用户角色", choices=UserRole.get_choices()) diff --git a/src/bk-user/bkuser/apis/web/basic/views.py b/src/bk-user/bkuser/apis/web/basic/views.py index cf089db04..a58c6da96 100644 --- a/src/bk-user/bkuser/apis/web/basic/views.py +++ b/src/bk-user/bkuser/apis/web/basic/views.py @@ -12,6 +12,8 @@ from rest_framework import generics, status from rest_framework.response import Response +from bkuser.apps.permission.permissions import get_user_role + from .serializers import CurrentUserRetrieveOutputSLZ @@ -24,9 +26,12 @@ class CurrentUserRetrieveApi(generics.RetrieveAPIView): def get(self, request, *args, **kwargs): # FIXME: 待新版登录后重构,return更多信息 current_user = request.user + current_tenant_id = current_user.get_property("tenant_id") + info = { "username": current_user.username, - "tenant_id": current_user.get_property("tenant_id"), + "tenant_id": current_tenant_id, + "role": get_user_role(current_tenant_id, current_user.username), } return Response(CurrentUserRetrieveOutputSLZ(instance=info).data) diff --git a/src/bk-user/bkuser/apis/web/data_source/views.py b/src/bk-user/bkuser/apis/web/data_source/views.py index 0b9b0d674..0e57e5cf0 100644 --- a/src/bk-user/bkuser/apis/web/data_source/views.py +++ b/src/bk-user/bkuser/apis/web/data_source/views.py @@ -16,6 +16,7 @@ from django.utils.translation import gettext_lazy as _ from drf_yasg.utils import swagger_auto_schema from rest_framework import generics, status +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from bkuser.apis.web.data_source.mixins import CurrentUserTenantDataSourceMixin @@ -42,6 +43,8 @@ from bkuser.apis.web.mixins import CurrentUserTenantMixin from bkuser.apps.data_source.constants import DataSourceStatus from bkuser.apps.data_source.models import DataSource, DataSourcePlugin, DataSourceSensitiveInfo +from bkuser.apps.permission.constants import PermAction +from bkuser.apps.permission.permissions import perm_class from bkuser.apps.sync.constants import SyncTaskTrigger from bkuser.apps.sync.data_models import DataSourceSyncOptions from bkuser.apps.sync.managers import DataSourceSyncManager @@ -91,6 +94,7 @@ def get(self, request, *args, **kwargs): class DataSourceListCreateApi(CurrentUserTenantMixin, generics.ListCreateAPIView): pagination_class = None + permission_classes = [IsAuthenticated, perm_class(PermAction.MANAGE_TENANT)] serializer_class = DataSourceSearchOutputSLZ def get_serializer_context(self): @@ -154,6 +158,7 @@ class DataSourceRetrieveUpdateApi( CurrentUserTenantDataSourceMixin, ExcludePatchAPIViewMixin, generics.RetrieveUpdateAPIView ): pagination_class = None + permission_classes = [IsAuthenticated, perm_class(PermAction.MANAGE_TENANT)] serializer_class = DataSourceRetrieveOutputSLZ @swagger_auto_schema( @@ -250,6 +255,7 @@ def post(self, request, *args, **kwargs): class DataSourceSwitchStatusApi(CurrentUserTenantDataSourceMixin, ExcludePutAPIViewMixin, generics.UpdateAPIView): """切换数据源状态(启/停)""" + permission_classes = [IsAuthenticated, perm_class(PermAction.MANAGE_TENANT)] serializer_class = DataSourceSwitchStatusOutputSLZ @swagger_auto_schema( @@ -274,6 +280,7 @@ class DataSourceTemplateApi(CurrentUserTenantDataSourceMixin, generics.ListAPIVi """获取本地数据源数据导入模板""" pagination_class = None + permission_classes = [IsAuthenticated, perm_class(PermAction.MANAGE_TENANT)] @swagger_auto_schema( tags=["data_source"], @@ -295,6 +302,7 @@ class DataSourceExportApi(CurrentUserTenantDataSourceMixin, generics.ListAPIView """本地数据源用户导出""" pagination_class = None + permission_classes = [IsAuthenticated, perm_class(PermAction.MANAGE_TENANT)] @swagger_auto_schema( tags=["data_source"], @@ -317,6 +325,8 @@ def get(self, request, *args, **kwargs): class DataSourceImportApi(CurrentUserTenantDataSourceMixin, generics.CreateAPIView): """从 Excel 导入数据源用户数据""" + permission_classes = [IsAuthenticated, perm_class(PermAction.MANAGE_TENANT)] + @swagger_auto_schema( tags=["data_source"], operation_description="本地数据源用户数据导入", @@ -371,6 +381,8 @@ def post(self, request, *args, **kwargs): class DataSourceSyncApi(CurrentUserTenantDataSourceMixin, generics.CreateAPIView): """数据源同步""" + permission_classes = [IsAuthenticated, perm_class(PermAction.MANAGE_TENANT)] + @swagger_auto_schema( tags=["data_source"], operation_description="数据源数据同步", @@ -412,6 +424,7 @@ def post(self, request, *args, **kwargs): class DataSourceSyncRecordListApi(CurrentUserTenantMixin, generics.ListAPIView): """数据源同步记录列表""" + permission_classes = [IsAuthenticated, perm_class(PermAction.MANAGE_TENANT)] serializer_class = DataSourceSyncRecordListOutputSLZ def get_queryset(self): @@ -449,6 +462,7 @@ class DataSourceSyncRecordRetrieveApi(CurrentUserTenantMixin, generics.RetrieveA """数据源同步记录详情""" lookup_url_kwarg = "id" + permission_classes = [IsAuthenticated, perm_class(PermAction.MANAGE_TENANT)] def get_queryset(self): return DataSourceSyncTask.objects.filter(data_source__owner_tenant_id=self.get_current_tenant_id()) diff --git a/src/bk-user/bkuser/apis/web/data_source_organization/views.py b/src/bk-user/bkuser/apis/web/data_source_organization/views.py index 1c8736d79..d1bf2e055 100644 --- a/src/bk-user/bkuser/apis/web/data_source_organization/views.py +++ b/src/bk-user/bkuser/apis/web/data_source_organization/views.py @@ -11,6 +11,7 @@ from django.db.models import Q from drf_yasg.utils import swagger_auto_schema from rest_framework import generics, status +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from bkuser.apis.web.data_source_organization.serializers import ( @@ -26,6 +27,8 @@ UserUpdateInputSLZ, ) from bkuser.apps.data_source.models import DataSource, DataSourceDepartment, DataSourceUser +from bkuser.apps.permission.constants import PermAction +from bkuser.apps.permission.permissions import perm_class from bkuser.biz.data_source_organization import ( DataSourceOrganizationHandler, DataSourceUserBaseInfo, @@ -39,6 +42,7 @@ class DataSourceUserListCreateApi(generics.ListCreateAPIView): serializer_class = UserSearchOutputSLZ lookup_url_kwarg = "id" + permission_classes = [IsAuthenticated, perm_class(PermAction.MANAGE_TENANT)] def get_queryset(self): slz = UserSearchInputSLZ(data=self.request.query_params) @@ -111,6 +115,7 @@ def post(self, request, *args, **kwargs): class DataSourceLeadersListApi(generics.ListAPIView): serializer_class = LeaderSearchOutputSLZ + permission_classes = [IsAuthenticated, perm_class(PermAction.MANAGE_TENANT)] def get_queryset(self): slz = LeaderSearchInputSLZ(data=self.request.query_params) @@ -139,6 +144,7 @@ def get(self, request, *args, **kwargs): class DataSourceDepartmentsListApi(generics.ListAPIView): + permission_classes = [IsAuthenticated, perm_class(PermAction.MANAGE_TENANT)] serializer_class = DepartmentSearchOutputSLZ def get_queryset(self): @@ -171,6 +177,7 @@ def get(self, request, *args, **kwargs): class DataSourceUserRetrieveUpdateApi(ExcludePatchAPIViewMixin, generics.RetrieveUpdateAPIView): queryset = DataSourceUser.objects.all() lookup_url_kwarg = "id" + permission_classes = [IsAuthenticated, perm_class(PermAction.MANAGE_TENANT)] serializer_class = UserRetrieveOutputSLZ def get_serializer_context(self): diff --git a/src/bk-user/bkuser/apis/web/organization/views.py b/src/bk-user/bkuser/apis/web/organization/views.py index 1e5bdd725..a2e1e99ca 100644 --- a/src/bk-user/bkuser/apis/web/organization/views.py +++ b/src/bk-user/bkuser/apis/web/organization/views.py @@ -13,6 +13,7 @@ from django.db.models import Q from drf_yasg.utils import swagger_auto_schema from rest_framework import generics, status +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from bkuser.apis.web.mixins import CurrentUserTenantMixin @@ -25,6 +26,8 @@ TenantUserSearchInputSLZ, ) from bkuser.apis.web.tenant.serializers import TenantRetrieveOutputSLZ, TenantUpdateInputSLZ +from bkuser.apps.permission.constants import PermAction +from bkuser.apps.permission.permissions import perm_class from bkuser.apps.tenant.models import Tenant, TenantUser from bkuser.biz.tenant import ( TenantDepartmentHandler, @@ -33,7 +36,6 @@ TenantHandler, TenantUserHandler, ) -from bkuser.common.error_codes import error_codes from bkuser.common.views import ExcludePatchAPIViewMixin logger = logging.getLogger(__name__) @@ -42,6 +44,7 @@ class TenantDepartmentUserListApi(CurrentUserTenantMixin, generics.ListAPIView): queryset = TenantUser.objects.all() lookup_url_kwarg = "id" + permission_classes = [IsAuthenticated, perm_class(PermAction.MANAGE_TENANT)] serializer_class = TenantUserListOutputSLZ def get_serializer_context(self): @@ -100,6 +103,7 @@ def get(self, request, *args, **kwargs): class TenantUserRetrieveApi(generics.RetrieveAPIView): queryset = TenantUser.objects.all() lookup_url_kwarg = "id" + permission_classes = [IsAuthenticated, perm_class(PermAction.MANAGE_TENANT)] serializer_class = TenantUserRetrieveOutputSLZ @swagger_auto_schema( @@ -113,6 +117,7 @@ def get(self, request, *args, **kwargs): class TenantListApi(CurrentUserTenantMixin, generics.ListAPIView): pagination_class = None + permission_classes = [IsAuthenticated, perm_class(PermAction.MANAGE_TENANT)] @swagger_auto_schema( tags=["tenant-organization"], @@ -145,6 +150,7 @@ def get(self, request, *args, **kwargs): class TenantRetrieveUpdateApi(ExcludePatchAPIViewMixin, CurrentUserTenantMixin, generics.RetrieveUpdateAPIView): queryset = Tenant.objects.all() pagination_class = None + permission_classes = [IsAuthenticated, perm_class(PermAction.MANAGE_TENANT)] serializer_class = TenantRetrieveOutputSLZ lookup_url_kwarg = "id" @@ -164,28 +170,25 @@ def get(self, request, *args, **kwargs): tags=["tenant-organization"], operation_description="更新租户", request_body=TenantUpdateInputSLZ(), - responses={status.HTTP_200_OK: ""}, + responses={status.HTTP_204_NO_CONTENT: ""}, ) def put(self, request, *args, **kwargs): slz = TenantUpdateInputSLZ(data=request.data) slz.is_valid(raise_exception=True) data = slz.validated_data - instance = self.get_object() - # NOTE 非当前租户, 无权限做更新操作 - if self.get_current_tenant_id() != instance.id: - raise error_codes.NO_PERMISSION - - should_updated_info = TenantEditableBaseInfo( + tenant = self.get_object() + tenant_info = TenantEditableBaseInfo( name=data["name"], logo=data["logo"] or "", feature_flags=TenantFeatureFlag(**data["feature_flags"]) ) - TenantHandler.update_with_managers(instance.id, should_updated_info, data["manager_ids"]) - return Response() + TenantHandler.update_with_managers(tenant.id, tenant_info, data["manager_ids"]) + return Response(status=status.HTTP_204_NO_CONTENT) class TenantDepartmentChildrenListApi(generics.ListAPIView): pagination_class = None + permission_classes = [IsAuthenticated, perm_class(PermAction.MANAGE_TENANT)] serializer_class = TenantDepartmentChildrenListOutputSLZ @swagger_auto_schema( @@ -204,6 +207,7 @@ def get(self, request, *args, **kwargs): class TenantUserListApi(CurrentUserTenantMixin, generics.ListAPIView): queryset = TenantUser.objects.all() lookup_url_kwarg = "id" + permission_classes = [IsAuthenticated, perm_class(PermAction.MANAGE_TENANT)] serializer_class = TenantUserListOutputSLZ def get_tenant_user_ids(self, tenant_id): diff --git a/src/bk-user/bkuser/apis/web/personal_center/views.py b/src/bk-user/bkuser/apis/web/personal_center/views.py index a96a892c2..e3a8425c0 100644 --- a/src/bk-user/bkuser/apis/web/personal_center/views.py +++ b/src/bk-user/bkuser/apis/web/personal_center/views.py @@ -12,6 +12,7 @@ from drf_yasg.utils import swagger_auto_schema from rest_framework import generics, status +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from bkuser.apis.web.personal_center.serializers import ( @@ -20,15 +21,17 @@ TenantUserEmailUpdateInputSLZ, TenantUserPhoneUpdateInputSLZ, ) +from bkuser.apps.permission.constants import PermAction +from bkuser.apps.permission.permissions import perm_class from bkuser.apps.tenant.models import TenantUser from bkuser.biz.natural_user import NatureUserHandler from bkuser.biz.tenant import TenantUserEmailInfo, TenantUserHandler, TenantUserPhoneInfo -from bkuser.common.error_codes import error_codes from bkuser.common.views import ExcludePutAPIViewMixin class NaturalUserTenantUserListApi(generics.ListAPIView): pagination_class = None + permission_classes = [IsAuthenticated, perm_class(PermAction.USE_PLATFORM)] @swagger_auto_schema( tags=["personal_center"], @@ -70,6 +73,7 @@ def get(self, request, *args, **kwargs): class TenantUserRetrieveApi(generics.RetrieveAPIView): queryset = TenantUser.objects.all() lookup_url_kwarg = "id" + permission_classes = [IsAuthenticated, perm_class(PermAction.USE_PLATFORM)] serializer_class = PersonalCenterTenantUserRetrieveOutputSLZ @swagger_auto_schema( @@ -78,22 +82,13 @@ class TenantUserRetrieveApi(generics.RetrieveAPIView): responses={status.HTTP_200_OK: PersonalCenterTenantUserRetrieveOutputSLZ()}, ) def get(self, request, *args, **kwargs): - instance: TenantUser = self.get_object() - - # 获取当前登录的租户用户的自然人 - nature_user = NatureUserHandler.get_nature_user_by_tenant_user_id(request.user.username) - - # 边界限制 - # 该租户用户的数据源用户,不属于当前自然人 - if instance.data_source_user_id not in nature_user.data_source_user_ids: - raise error_codes.NO_PERMISSION - - return Response(PersonalCenterTenantUserRetrieveOutputSLZ(instance).data) + return Response(PersonalCenterTenantUserRetrieveOutputSLZ(self.get_object()).data) class TenantUserPhoneUpdateApi(ExcludePutAPIViewMixin, generics.UpdateAPIView): queryset = TenantUser.objects.all() lookup_url_kwarg = "id" + permission_classes = [IsAuthenticated, perm_class(PermAction.USE_PLATFORM)] @swagger_auto_schema( tags=["personal_center"], @@ -102,33 +97,23 @@ class TenantUserPhoneUpdateApi(ExcludePutAPIViewMixin, generics.UpdateAPIView): responses={status.HTTP_200_OK: ""}, ) def patch(self, request, *args, **kwargs): - instance: TenantUser = self.get_object() - - # 获取当前登录的租户用户的自然人 - nature_user = NatureUserHandler.get_nature_user_by_tenant_user_id(request.user.username) - - # 边界限制 - # 该租户用户的数据源用户,不属于当前自然人 - if instance.data_source_user_id not in nature_user.data_source_user_ids: - raise error_codes.NO_PERMISSION - slz = TenantUserPhoneUpdateInputSLZ(data=request.data) slz.is_valid(raise_exception=True) - data = slz.validated_data + phone_info = TenantUserPhoneInfo( is_inherited_phone=data["is_inherited_phone"], custom_phone=data.get("custom_phone", ""), custom_phone_country_code=data["custom_phone_country_code"], ) - TenantUserHandler.update_tenant_user_phone(instance, phone_info) - + TenantUserHandler.update_tenant_user_phone(self.get_object(), phone_info) return Response() class TenantUserEmailUpdateApi(ExcludePutAPIViewMixin, generics.UpdateAPIView): queryset = TenantUser.objects.all() lookup_url_kwarg = "id" + permission_classes = [IsAuthenticated, perm_class(PermAction.USE_PLATFORM)] @swagger_auto_schema( tags=["personal_center"], @@ -137,23 +122,13 @@ class TenantUserEmailUpdateApi(ExcludePutAPIViewMixin, generics.UpdateAPIView): responses={status.HTTP_200_OK: ""}, ) def patch(self, request, *args, **kwargs): - instance: TenantUser = self.get_object() - - # 获取当前登录的租户用户的自然人 - nature_user = NatureUserHandler.get_nature_user_by_tenant_user_id(request.user.username) - - # 边界限制 - # 该租户用户的数据源用户,不属于当前自然人下的 - if instance.data_source_user_id not in nature_user.data_source_user_ids: - raise error_codes.NO_PERMISSION - slz = TenantUserEmailUpdateInputSLZ(data=request.data) slz.is_valid(raise_exception=True) - data = slz.validated_data + email_info = TenantUserEmailInfo( - is_inherited_email=data["is_inherited_email"], custom_email=data.get("custom_email", "") + is_inherited_email=data["is_inherited_email"], + custom_email=data.get("custom_email", ""), ) - TenantUserHandler.update_tenant_user_email(instance, email_info) - + TenantUserHandler.update_tenant_user_email(self.get_object(), email_info) return Response() diff --git a/src/bk-user/bkuser/apis/web/tenant/views.py b/src/bk-user/bkuser/apis/web/tenant/views.py index c11c5335d..d0c9165a9 100644 --- a/src/bk-user/bkuser/apis/web/tenant/views.py +++ b/src/bk-user/bkuser/apis/web/tenant/views.py @@ -13,6 +13,7 @@ from django.db.models import Q from drf_yasg.utils import swagger_auto_schema from rest_framework import generics, status +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from bkuser.apis.web.tenant.serializers import ( @@ -25,6 +26,8 @@ TenantUserSearchInputSLZ, TenantUserSearchOutputSLZ, ) +from bkuser.apps.permission.constants import PermAction +from bkuser.apps.permission.permissions import perm_class from bkuser.apps.tenant.models import Tenant, TenantUser from bkuser.biz.data_source import DataSourceHandler from bkuser.biz.tenant import ( @@ -42,7 +45,7 @@ class TenantListCreateApi(generics.ListCreateAPIView): pagination_class = None - + permission_classes = [IsAuthenticated, perm_class(PermAction.MANAGE_PLATFORM)] serializer_class = TenantSearchOutputSLZ def get_serializer_context(self): @@ -109,6 +112,7 @@ def post(self, request, *args, **kwargs): class TenantRetrieveUpdateApi(ExcludePatchAPIViewMixin, generics.RetrieveUpdateAPIView): queryset = Tenant.objects.all() lookup_url_kwarg = "id" + permission_classes = [IsAuthenticated, perm_class(PermAction.MANAGE_PLATFORM)] serializer_class = TenantRetrieveOutputSLZ def get_serializer_context(self): @@ -150,6 +154,7 @@ def put(self, request, *args, **kwargs): class TenantUsersListApi(generics.ListAPIView): serializer_class = TenantUserSearchOutputSLZ + permission_classes = [IsAuthenticated, perm_class(PermAction.MANAGE_PLATFORM)] def get_queryset(self): tenant_id = self.kwargs["tenant_id"] diff --git a/src/bk-user/bkuser/apis/web/tenant_setting/views.py b/src/bk-user/bkuser/apis/web/tenant_setting/views.py index 8be22b64c..4e8bc4c33 100644 --- a/src/bk-user/bkuser/apis/web/tenant_setting/views.py +++ b/src/bk-user/bkuser/apis/web/tenant_setting/views.py @@ -11,6 +11,7 @@ from drf_yasg.utils import swagger_auto_schema from rest_framework import generics, status from rest_framework.generics import get_object_or_404 +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from bkuser.apis.web.mixins import CurrentUserTenantMixin @@ -22,18 +23,19 @@ TenantUserValidityPeriodConfigInputSLZ, TenantUserValidityPeriodConfigOutputSLZ, ) +from bkuser.apps.permission.constants import PermAction +from bkuser.apps.permission.permissions import perm_class from bkuser.apps.tenant.models import ( - TenantManager, TenantUserCustomField, TenantUserValidityPeriodConfig, UserBuiltinField, ) -from bkuser.common.error_codes import error_codes from bkuser.common.views import ExcludePatchAPIViewMixin, ExcludePutAPIViewMixin class TenantUserFieldListApi(CurrentUserTenantMixin, generics.ListAPIView): pagination_class = None + permission_classes = [IsAuthenticated, perm_class(PermAction.MANAGE_TENANT)] serializer_class = TenantUserFieldOutputSLZ @swagger_auto_schema( @@ -54,6 +56,8 @@ def get(self, request, *args, **kwargs): class TenantUserCustomFieldCreateApi(CurrentUserTenantMixin, generics.CreateAPIView): + permission_classes = [IsAuthenticated, perm_class(PermAction.MANAGE_TENANT)] + @swagger_auto_schema( tags=["tenant-setting"], operation_description="新建用户自定义字段", @@ -77,6 +81,7 @@ class TenantUserCustomFieldUpdateDeleteApi( CurrentUserTenantMixin, ExcludePutAPIViewMixin, generics.UpdateAPIView, generics.DestroyAPIView ): lookup_url_kwarg = "id" + permission_classes = [IsAuthenticated, perm_class(PermAction.MANAGE_TENANT)] def get_queryset(self): return TenantUserCustomField.objects.filter(tenant_id=self.get_current_tenant_id()) @@ -117,6 +122,8 @@ def delete(self, request, *args, **kwargs): class TenantUserValidityPeriodConfigRetrieveUpdateApi( ExcludePatchAPIViewMixin, CurrentUserTenantMixin, generics.RetrieveUpdateAPIView ): + permission_classes = [IsAuthenticated, perm_class(PermAction.MANAGE_TENANT)] + def get_object(self): queryset = TenantUserValidityPeriodConfig.objects.all() filter_kwargs = {"tenant_id": self.get_current_tenant_id()} @@ -142,24 +149,17 @@ def get(self, request, *args, **kwargs): }, ) def put(self, request, *args, **kwargs): - instance = self.get_object() - - # TODO (su) 权限调整为 perm_class 当前租户的管理才可做更新操作 - operator = request.user.username - if not TenantManager.objects.filter(tenant_id=instance.tenant_id, tenant_user_id=operator).exists(): - raise error_codes.NO_PERMISSION - slz = TenantUserValidityPeriodConfigInputSLZ(data=request.data) slz.is_valid(raise_exception=True) data = slz.validated_data - instance.enabled = data["enabled"] - instance.validity_period = data["validity_period"] - instance.remind_before_expire = data["remind_before_expire"] - instance.enabled_notification_methods = data["enabled_notification_methods"] - instance.notification_templates = data["notification_templates"] - instance.updater = operator - - instance.save() + cfg = self.get_object() + cfg.enabled = data["enabled"] + cfg.validity_period = data["validity_period"] + cfg.remind_before_expire = data["remind_before_expire"] + cfg.enabled_notification_methods = data["enabled_notification_methods"] + cfg.notification_templates = data["notification_templates"] + cfg.updater = request.user.username + cfg.save() return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/src/bk-user/bkuser/apps/permission/__init__.py b/src/bk-user/bkuser/apps/permission/__init__.py new file mode 100644 index 000000000..1060b7bf4 --- /dev/null +++ b/src/bk-user/bkuser/apps/permission/__init__.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. +""" diff --git a/src/bk-user/bkuser/apps/permission/apps.py b/src/bk-user/bkuser/apps/permission/apps.py new file mode 100644 index 000000000..71b90bcb4 --- /dev/null +++ b/src/bk-user/bkuser/apps/permission/apps.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. +""" +from django.apps import AppConfig + + +class PermissionConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "bkuser.apps.permission" diff --git a/src/bk-user/bkuser/apps/permission/constants.py b/src/bk-user/bkuser/apps/permission/constants.py new file mode 100644 index 000000000..e06952daf --- /dev/null +++ b/src/bk-user/bkuser/apps/permission/constants.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. +""" +import logging + +from blue_krill.data_types.enum import EnumField, StructuredEnum +from django.utils.translation import gettext_lazy as _ + +logger = logging.getLogger(__name__) + + +class UserRole(str, StructuredEnum): + """用户角色""" + + SUPER_MANAGER = EnumField("super_manager", label=_("超级管理员")) + TENANT_MANAGER = EnumField("tenant_manager", label=_("租户管理员")) + NATURAL_USER = EnumField("natural_user", label=_("普通用户")) + + +class PermAction(str, StructuredEnum): + """权限行为""" + + # TODO (su) 接入 IAM 时,需要评估是否细化 Action + # 平台管理 - 超级管理员 + MANAGE_PLATFORM = EnumField("manage_platform", label=_("平台管理")) + # 租户管理 - 租户管理员 + MANAGE_TENANT = EnumField("manage_tenant", label=_("租户管理")) + # 平台使用 - 普通用户/自然人 + USE_PLATFORM = EnumField("use_platform", label=_("平台使用")) diff --git a/src/bk-user/bkuser/apps/permission/permissions.py b/src/bk-user/bkuser/apps/permission/permissions.py new file mode 100644 index 000000000..b599e24b9 --- /dev/null +++ b/src/bk-user/bkuser/apps/permission/permissions.py @@ -0,0 +1,134 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making +蓝鲸智云 - PaaS 平台 (BlueKing - PaaS System) available. +Copyright (C) 2017 THL A29 Limited, a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); you may not use this file except +in compliance with the License. You may obtain a copy of the License at + + http://opensource.org/licenses/MIT + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +either express or implied. See the License for the specific language governing permissions and +limitations under the License. + +We undertake not to change the open source license (MIT license) applicable +to the current version of the project delivered to anyone in the future. +""" +import logging + +from rest_framework.permissions import BasePermission + +from bkuser.apps.data_source.models import DataSource +from bkuser.apps.natural_user.models import DataSourceUserNaturalUserRelation +from bkuser.apps.permission.constants import PermAction, UserRole +from bkuser.apps.tenant.models import Tenant, TenantManager, TenantUser + +logger = logging.getLogger(__name__) + + +def perm_class(action: PermAction): # noqa: C901 + """构建 DRF 可用的应用权限类""" + + class Permission(BasePermission): + def has_permission(self, request, view): + username = request.user.username + cur_tenant_id = request.user.get_property("tenant_id") + + if action == PermAction.MANAGE_PLATFORM: + return is_super_manager(cur_tenant_id, username) + if action == PermAction.MANAGE_TENANT: + return is_tenant_manager(cur_tenant_id, username) + if action == PermAction.USE_PLATFORM: + # 平台使用的情况,需要用具体的 object 来判断权限 + return True + + return False + + def has_object_permission(self, request, view, obj): + if isinstance(obj, Tenant): + tenant_id = obj.id + elif hasattr(obj, "tenant_id"): + tenant_id = obj.tenant_id + elif isinstance(obj, DataSource): + # TODO (su) 考虑数据源协同的情况 + tenant_id = obj.owner_tenant_id + elif hasattr(obj, "data_source"): + # TODO (su) 考虑数据源协同的情况 + tenant_id = obj.data_source.owner_tenant_id + else: + logger.exception("failed to get tenant id, obj: %s", obj) + return False + + username = request.user.username + cur_tenant_id = request.user.get_property("tenant_id") + if action == PermAction.MANAGE_PLATFORM: + return is_super_manager(tenant_id, username) + if action == PermAction.MANAGE_TENANT: + return is_tenant_manager(tenant_id, username) + if action == PermAction.USE_PLATFORM: + # 当前平台使用(普通用户)能编辑的资源只有 TenantUser + if not isinstance(obj, TenantUser): + return False + + return is_same_nature_user(obj.id, cur_tenant_id, username) + + return False + + return Permission + + +def is_super_manager(tenant_id: str, username: str) -> bool: + """默认租户的管理员,有管理平台的权限(超级管理员)""" + tenant = Tenant.objects.get(id=tenant_id) + if not tenant.is_default: + return False + + return TenantManager.objects.filter(tenant=tenant, tenant_user_id=username).exists() + + +def is_tenant_manager(tenant_id: str, username: str) -> bool: + """本租户的管理员,拥有管理当前租户配置的权限""" + tenant = Tenant.objects.get(id=tenant_id) + return TenantManager.objects.filter(tenant=tenant, tenant_user_id=username).exists() + + +def is_same_nature_user(req_username: str, cur_tenant_id: str, username: str) -> bool: + """判断是否同一自然人(可以跨租户访问属于同一自然人/数据源用户的数据) + + :param req_username: 待访问租户用户名 + :param cur_tenant_id: 当前用户的租户 ID + :param username: 当前用户的用户名 + """ + cur_tenant_user = TenantUser.objects.filter(tenant_id=cur_tenant_id, id=username).first() + # 当前登录的,连租户用户都不是,自然不是自然人 + if not cur_tenant_user: + return False + + # 数据用户源若绑定到自然人,只可能有一条记录 + relation = DataSourceUserNaturalUserRelation.objects.filter( + data_source_user=cur_tenant_user.data_source_user + ).first() + + # 如果没有绑定自然人,则将同一数据源用户关联的租户用户,都视作一个自然人 + if not relation: + return TenantUser.objects.filter(id=req_username, data_source_user=cur_tenant_user.data_source_user).exists() + + data_source_user_ids = DataSourceUserNaturalUserRelation.objects.filter( + natural_user=relation.natural_user + ).values_list("data_source_user_id", flat=True) + return TenantUser.objects.filter(id=req_username, data_source_user__in=data_source_user_ids).exists() + + +def get_user_role(tenant_id: str, username: str) -> UserRole: + """获取用户角色,因目前超级管理员必定是租户管理员,租户管理员必定是普通用户,因此返回最高级的角色即可""" + tenant = Tenant.objects.get(id=tenant_id) + + if TenantManager.objects.filter(tenant=tenant, tenant_user_id=username).exists(): + if tenant.is_default: + return UserRole.SUPER_MANAGER + + return UserRole.TENANT_MANAGER + + return UserRole.NATURAL_USER diff --git a/src/bk-user/bkuser/settings.py b/src/bk-user/bkuser/settings.py index 1077573f5..7db62f529 100644 --- a/src/bk-user/bkuser/settings.py +++ b/src/bk-user/bkuser/settings.py @@ -55,6 +55,7 @@ "bkuser.apps.sync", "bkuser.apps.idp", "bkuser.apps.natural_user", + "bkuser.apps.permission", ] MIDDLEWARE = [ diff --git a/src/bk-user/pyproject.toml b/src/bk-user/pyproject.toml index 02f08f905..4af980417 100644 --- a/src/bk-user/pyproject.toml +++ b/src/bk-user/pyproject.toml @@ -158,7 +158,7 @@ layers = [ name = "Apps Layers contract" type = "layers" layers = [ - "bkuser.apps.sync", + "bkuser.apps.sync | bkuser.apps.permission", "bkuser.apps.tenant", "bkuser.apps.idp", "bkuser.apps.data_source", diff --git a/src/bk-user/tests/conftest.py b/src/bk-user/tests/conftest.py index 58371c2f3..c0fa7c675 100644 --- a/src/bk-user/tests/conftest.py +++ b/src/bk-user/tests/conftest.py @@ -43,4 +43,4 @@ def random_tenant() -> Tenant: @pytest.fixture() def bk_user(default_tenant) -> User: """生成随机用户""" - return create_user() + return create_user(default_tenant) diff --git a/src/bk-user/tests/test_utils/auth.py b/src/bk-user/tests/test_utils/auth.py index a63c1d2bd..ecbf8f1c5 100644 --- a/src/bk-user/tests/test_utils/auth.py +++ b/src/bk-user/tests/test_utils/auth.py @@ -12,20 +12,19 @@ from typing import Optional from bkuser.apps.data_source.models import DataSource, DataSourceUser -from bkuser.apps.tenant.models import TenantUser +from bkuser.apps.tenant.models import Tenant, TenantManager, TenantUser from bkuser.auth.models import User from tests.test_utils.helpers import generate_random_string -from tests.test_utils.tenant import DEFAULT_TENANT -def create_user(username: Optional[str] = None) -> User: +def create_user(tenant: Tenant, username: Optional[str] = None) -> User: """创建测试用用户""" username = username or generate_random_string(length=8) user, _ = User.objects.get_or_create(username=username) - user.set_property("tenant_id", DEFAULT_TENANT) + user.set_property("tenant_id", tenant.id) # 获取租户默认的本地数据源 - data_source = DataSource.objects.get(owner_tenant_id=DEFAULT_TENANT, name=f"{DEFAULT_TENANT}-default-local") + data_source = DataSource.objects.get(owner_tenant_id=tenant.id, name=f"{tenant.id}-default-local") data_source_user, _ = DataSourceUser.objects.get_or_create( username=username, @@ -37,8 +36,13 @@ def create_user(username: Optional[str] = None) -> User: }, ) - TenantUser.objects.get_or_create( - tenant_id=DEFAULT_TENANT, id=username, data_source=data_source, data_source_user=data_source_user + tenant_user, _ = TenantUser.objects.get_or_create( + tenant=tenant, + id=username, + data_source=data_source, + data_source_user=data_source_user, ) + TenantManager.objects.get_or_create(tenant=tenant, tenant_user=tenant_user) + return user