diff --git a/.gitignore b/.gitignore index c1d3ef072..77570079c 100644 --- a/.gitignore +++ b/.gitignore @@ -227,3 +227,6 @@ deploy/helm/dist/ deploy/helm/api/templates/c_*.* deploy/helm/saas/templates/c_*.* deploy/helm/bk-user-stack/templates/c_*.* + +# local hooks +pre_commit_hooks \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7f4084666..b4cbc4463 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,16 @@ repos: - repo: local hooks: + - id: Name check + name: Check sensitive info + verbose: true + language: script + entry: pre_commit_hooks/rtx.sh + - id: IP check + name: Check sensitive info + verbose: true + language: script + entry: pre_commit_hooks/ip.sh - id: isort name: isort language: python diff --git a/deploy/helm/api/Chart.yaml b/deploy/helm/api/Chart.yaml index 6048f9a1e..ab9e4a49b 100644 --- a/deploy/helm/api/Chart.yaml +++ b/deploy/helm/api/Chart.yaml @@ -1,5 +1,5 @@ apiVersion: v2 -appVersion: v2.3.0 +appVersion: v2.3.1 description: A Helm chart for bk user api name: bkuserapi type: application diff --git a/deploy/helm/api/values.yaml b/deploy/helm/api/values.yaml index d7ce5f925..dcd474584 100644 --- a/deploy/helm/api/values.yaml +++ b/deploy/helm/api/values.yaml @@ -150,7 +150,7 @@ processes: replicas: 1 resources: limits: - cpu: 200m + cpu: 1024m memory: 512Mi requests: cpu: 100m @@ -192,5 +192,49 @@ volumeMounts: [] # 支持定义 configmaps configMaps: [] +# 当 Chart 独立部署时,默认关闭内建存储 mariadb: - enabled: false \ No newline at end of file + enabled: false + +## ServiceMonitor configuration +## +serviceMonitor: + ## @param serviceMonitor.enabled Creates a ServiceMonitor to monitor kube-state-metrics + ## + enabled: false + ## @param serviceMonitor.jobLabel The name of the label on the target service to use as the job name in prometheus. + ## + jobLabel: "" + ## @param serviceMonitor.interval Scrape interval (use by default, falling back to Prometheus' default) + ## ref: https://github.com/coreos/prometheus-operator/blob/master/Documentation/api.md#endpoint + ## e.g: + ## interval: 10s + ## + interval: "" + ## @param serviceMonitor.scrapeTimeout Timeout after which the scrape is ended + ## ref: https://github.com/coreos/prometheus-operator/blob/master/Documentation/api.md#endpoint + ## e.g: + ## scrapeTimeout: 10s + ## + scrapeTimeout: "" + ## @param serviceMonitor.selector ServiceMonitor selector labels + ## ref: https://github.com/bitnami/charts/tree/master/bitnami/prometheus-operator#prometheus-configuration + ## e.g: + ## selector: + ## prometheus: my-prometheus + ## + selector: {} + ## @param serviceMonitor.honorLabels Honor metrics labels + ## ref: https://github.com/coreos/prometheus-operator/blob/master/Documentation/api.md#endpoint + ## e.g: + ## honorLabels: false + ## + honorLabels: false + ## @param serviceMonitor.relabelings ServiceMonitor relabelings + ## ref: https://github.com/coreos/prometheus-operator/blob/master/Documentation/api.md#relabelconfig + ## + relabelings: [] + ## @param serviceMonitor.metricRelabelings ServiceMonitor metricRelabelings + ## ref: https://github.com/coreos/prometheus-operator/blob/master/Documentation/api.md#relabelconfig + ## + metricRelabelings: [] diff --git a/deploy/helm/bk-user-stack/Chart.yaml b/deploy/helm/bk-user-stack/Chart.yaml index 331f9c134..eaeda5995 100644 --- a/deploy/helm/bk-user-stack/Chart.yaml +++ b/deploy/helm/bk-user-stack/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: bk-user-stack description: A Helm chart for bk-user type: application -version: 0.5.4 -appVersion: v2.3.0 +version: 0.5.6 +appVersion: v2.3.1 dependencies: - name: bkuserapi @@ -24,4 +24,4 @@ dependencies: - name: redis version: "14.x.x" repository: "https://charts.bitnami.com/bitnami" - condition: redis.enabled \ No newline at end of file + condition: redis.enabled diff --git a/deploy/helm/bk-user-stack/README.md b/deploy/helm/bk-user-stack/README.md index 9b5340a43..b8f086f4d 100644 --- a/deploy/helm/bk-user-stack/README.md +++ b/deploy/helm/bk-user-stack/README.md @@ -128,6 +128,10 @@ redis: #### 5. 权限中心 默认地,我们未开启权限中心,如果在权限中心已经就绪之后,想体验用户管理功能,那么你可以手动向权限中心注册模型: ```yaml +global: + env: + ENABLE_IAM: true + bkuserapi: env: # 填充权限中心相关变量 @@ -136,11 +140,6 @@ bkuserapi: preRunHooks: bkiam-migrate: enabled: true - -bkusersaas: - env: - # 主动开启用户管理 SaaS 权限校验 - DISABLE_IAM: false ``` #### 6. 账号密码 @@ -153,6 +152,24 @@ bkuserapi: INITIAL_ADMIN_PASSWORD: "your-super-strong-password" ``` +#### 7. 如何扩容 +我们支持对任意进程进行扩容,就像这样: +```yaml +bkuserapi: + processes: + web: + replicas: 3 + celery: + replicas: 2 + beat: + replicas: 1 (切记,beat 进程只能存在一个副本,否则后台任务会重复执行) + +bkusersaas: + processes: + web: + replicas: 2 +``` + ### 安装 如果你已经准备好了 `values.yaml`,就可以直接进行安装操作了 diff --git a/deploy/helm/bk-user-stack/values.yaml b/deploy/helm/bk-user-stack/values.yaml index 106b21bfe..0c39ce525 100644 --- a/deploy/helm/bk-user-stack/values.yaml +++ b/deploy/helm/bk-user-stack/values.yaml @@ -6,6 +6,11 @@ global: registry: "ccr.ccs.tencentyun.com/bk.io" pullPolicy: Always + # 日志采集,默认关闭,当日志采集就绪时,手动开启 + bkLogConfig: + enabled: false + dataId: "" + env: # 请在 PaaS 产品就绪后,查询 secret 并填入,否则影响用户管理调用 ESB 的相关功能(邮件通知等) BK_APP_SECRET: "your-own-secret" @@ -14,6 +19,8 @@ global: BK_PAAS_URL: "http://paas.example.com" # ESB Api 访问地址 BK_COMPONENT_API_URL: "http://bkapi.example.com" + # 由于用户管理先于权限中心拉起,所以默认禁用,后期所有产品就绪后,可手动开启 + ENABLE_IAM: false bkuserapi: enabeld: true @@ -28,6 +35,10 @@ bkuserapi: - configMapRef: name: bk-user-api-redis-env + # 默认我们关闭了监控采集,当监控就绪时,请手动开启 + # serviceMonitor: + # enabled: true + bkusersaas: enabled: true envFrom: @@ -35,6 +46,10 @@ bkusersaas: - configMapRef: name: bk-user-saas-mariadb-env + # 默认我们关闭了监控采集,当监控就绪时,请手动开启 + # serviceMonitor: + # enabled: true + # ------------- # 内建存储配置 # 默认通过 .Release.Name 拼接访问,请不要配置 nameOverride 或 fullnameOverride diff --git a/deploy/helm/chartty/c_base.tpl b/deploy/helm/chartty/c_base.tpl index 9ab005728..07da37b7f 100644 --- a/deploy/helm/chartty/c_base.tpl +++ b/deploy/helm/chartty/c_base.tpl @@ -67,3 +67,17 @@ Create the name of the service account to use {{- end }} {{- end }} {{- end }} + +{{/* vim: set filetype=mustache: */}} +{{/* +Renders a value that contains template. +Usage: +{{ include "chartty.tplvalues.render" ( dict "value" .Values.path.to.the.Value "context" $) }} +*/}} +{{- define "chartty.tplvalues.render" -}} + {{- if typeIs "string" .value }} + {{- tpl .value .context }} + {{- else }} + {{- tpl (.value | toYaml) .context }} + {{- end }} +{{- end -}} \ No newline at end of file diff --git a/deploy/helm/chartty/c_bklogconfig.yaml b/deploy/helm/chartty/c_bklogconfig.yaml new file mode 100644 index 000000000..32b6e8f1b --- /dev/null +++ b/deploy/helm/chartty/c_bklogconfig.yaml @@ -0,0 +1,16 @@ +{{- $global := . }} +{{- $namePrefix := include "chartty.name" . -}} +{{- if .Values.global.bkLogConfig.enabled }} +apiVersion: bk.tencent.com/v1alpha1 +kind: BkLogConfig +metadata: + name: {{ $namePrefix }}-stdout-log +spec: + dataId: {{ .Values.global.bkLogConfig.dataId }} + logConfigType: std_log_config + namespace: {{ .Release.Namespace | quote }} + container_name_match: + - {{ $namePrefix }} + labelSelector: + matchLabels: {{- include "chartty.labels" $global | nindent 6 }} +{{- end }} \ No newline at end of file diff --git a/deploy/helm/chartty/c_servicemonitor.yaml b/deploy/helm/chartty/c_servicemonitor.yaml new file mode 100644 index 000000000..3d8098b84 --- /dev/null +++ b/deploy/helm/chartty/c_servicemonitor.yaml @@ -0,0 +1,38 @@ +{{- $global := . }} +{{- if .Values.serviceMonitor.enabled }} +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: {{ template "chartty.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "chartty.labels" $global | nindent 4 }} +spec: + {{- if .Values.serviceMonitor.jobLabel }} + jobLabel: {{ .Values.serviceMonitor.jobLabel }} + {{- end }} + selector: + matchLabels: + {{- include "chartty.selectorLabels" $global | nindent 6 }} + endpoints: + - port: http + path: "/metrics" + {{- if .Values.serviceMonitor.interval }} + interval: {{ .Values.serviceMonitor.interval }} + {{- end }} + {{- if .Values.serviceMonitor.scrapeTimeout }} + scrapeTimeout: {{ .Values.serviceMonitor.scrapeTimeout }} + {{- end }} + {{- if hasKey .Values.serviceMonitor "honorLabels" }} + honorLabels: {{ .Values.serviceMonitor.honorLabels }} + {{- end }} + {{- if .Values.serviceMonitor.relabelings }} + relabelings: {{- include "chartty.tplvalues.render" ( dict "value" .Values.serviceMonitor.relabelings "context" $) | nindent 8 }} + {{- end }} + {{- if .Values.serviceMonitor.metricRelabelings }} + metricRelabelings: {{- include "chartty.tplvalues.render" ( dict "value" .Values.serviceMonitor.metricRelabelings "context" $) | nindent 8 }} + {{- end }} + namespaceSelector: + matchNames: + - {{ .Release.Namespace }} +{{- end }} \ No newline at end of file diff --git a/deploy/helm/saas/Chart.yaml b/deploy/helm/saas/Chart.yaml index 1d70e0dd1..6c6f51fc0 100644 --- a/deploy/helm/saas/Chart.yaml +++ b/deploy/helm/saas/Chart.yaml @@ -1,5 +1,5 @@ apiVersion: v2 -appVersion: v2.3.0 +appVersion: v2.3.1 description: A Helm chart for bk user saas name: bkusersaas type: application diff --git a/deploy/helm/saas/values.yaml b/deploy/helm/saas/values.yaml index 2f6547bd7..b5febf931 100644 --- a/deploy/helm/saas/values.yaml +++ b/deploy/helm/saas/values.yaml @@ -81,8 +81,6 @@ env: BK_LOGIN_API_URL: "http://bk-login-web" # 容器化版本默认采用子域名形式暴露服务 SITE_URL: "/" - # 由于用户管理先于权限中心拉起,所以默认禁用,后期所有产品就绪后,可手动开启 - DISABLE_IAM: true envFrom: [] @@ -113,7 +111,7 @@ processes: replicas: 1 resources: limits: - cpu: 200m + cpu: 1024m memory: 1024Mi requests: cpu: 200m @@ -167,5 +165,49 @@ configMaps: [] # data: # debug: true +# 当 Chart 独立部署时,默认关闭内建存储 mariadb: - enabled: false \ No newline at end of file + enabled: false + +## ServiceMonitor configuration +## +serviceMonitor: + ## @param serviceMonitor.enabled Creates a ServiceMonitor to monitor kube-state-metrics + ## + enabled: false + ## @param serviceMonitor.jobLabel The name of the label on the target service to use as the job name in prometheus. + ## + jobLabel: "" + ## @param serviceMonitor.interval Scrape interval (use by default, falling back to Prometheus' default) + ## ref: https://github.com/coreos/prometheus-operator/blob/master/Documentation/api.md#endpoint + ## e.g: + ## interval: 10s + ## + interval: "" + ## @param serviceMonitor.scrapeTimeout Timeout after which the scrape is ended + ## ref: https://github.com/coreos/prometheus-operator/blob/master/Documentation/api.md#endpoint + ## e.g: + ## scrapeTimeout: 10s + ## + scrapeTimeout: "" + ## @param serviceMonitor.selector ServiceMonitor selector labels + ## ref: https://github.com/bitnami/charts/tree/master/bitnami/prometheus-operator#prometheus-configuration + ## e.g: + ## selector: + ## prometheus: my-prometheus + ## + selector: {} + ## @param serviceMonitor.honorLabels Honor metrics labels + ## ref: https://github.com/coreos/prometheus-operator/blob/master/Documentation/api.md#endpoint + ## e.g: + ## honorLabels: false + ## + honorLabels: false + ## @param serviceMonitor.relabelings ServiceMonitor relabelings + ## ref: https://github.com/coreos/prometheus-operator/blob/master/Documentation/api.md#relabelconfig + ## + relabelings: [] + ## @param serviceMonitor.metricRelabelings ServiceMonitor metricRelabelings + ## ref: https://github.com/coreos/prometheus-operator/blob/master/Documentation/api.md#relabelconfig + ## + metricRelabelings: [] \ No newline at end of file diff --git a/docs/develop_api_guide.md b/docs/develop_api_guide.md index ef9d080b1..cf12c849b 100644 --- a/docs/develop_api_guide.md +++ b/docs/develop_api_guide.md @@ -48,35 +48,9 @@ bin/start_beat.sh ### LDAP & MAD 测试 -由于内置了 `Ldap & MAD` 的功能,所以需要额外配置资源 - -可以使用 `Docker` 来启动 `Ldap` 服务进行测试: - -``` bash -# openLdap -docker run -p 389:389 -p 636:636 \ - --name my-openldap-container \ - --detach osixia/openldap:1.3.0 - -# ldap admin -docker run -p 6443:443 \ - --env PHPLDAPADMIN_LDAP_HOSTS=docker.for.mac.host.internal \ - --detach osixia/phpldapadmin:0.9.0 -``` - -使用 `bkuser_core/tests/categories/vendors/ldap/assets/ldap.ldif` 直接导入必须的数据。 - -最后,在 `dev.py` 中配置 - +你需要确保在 `dev.py` 中已添加 LDAP mock 配置 ``` python -TEST_LDAP = { - "url": "localhost", - "base": "dc=example,dc=org", - "user": "cn=admin,dc=example,dc=org", - "password": "admin", - "user_class": "inetOrgPerson", - "organization_class": "organizationalUnit", -} +LDAP_CONNECTION_EXTRAS_PARAMS = {"client_strategy": ldap3.MOCK_SYNC} ``` 运行单元测试 diff --git a/docs/release.md b/docs/release.md index 54f2f7e95..f0d200ac3 100644 --- a/docs/release.md +++ b/docs/release.md @@ -2,6 +2,30 @@ # Changelog +## [Version: 2.3.1] - 2021-11-05 + + +### API + + +- [NEW] API 支持通过 POST body 筛选数据 [#88](https://github.com/TencentBlueKing/bk-user/issues/88) +- [NEW] 支持审计记录失败内容(仅数据) [#71](https://github.com/TencentBlueKing/bk-user/issues/71) +- [FIX] 修复 ldap/mad 测试连接按钮报错问题 [#129](https://github.com/TencentBlueKing/bk-user/issues/129) +- [FIX] 修复手动关闭权限中心时,目录新建关联权限报错问题 [#99](https://github.com/TencentBlueKing/bk-user/issues/99) +- [FIX] 修复部门查询接口 ?lookup_field=name,当部门名称中含有 . 时返回 404 问题 [#147](https://github.com/TencentBlueKing/bk-user/issues/147) +- [FIX] 修复 Excel 模板字段名与内置字段名不统一,导致导入失败问题 [#150](https://github.com/TencentBlueKing/bk-user/issues/150) +- [OPTIMIZATION] 将「密码过期判断」逻辑调整到「密码校验成功」后,规避可能存在的安全风险 [#137](https://github.com/TencentBlueKing/bk-user/issues/137) + + +### SaaS + + +- [NEW] 支持搜索已删除的数据 [#80](https://github.com/TencentBlueKing/bk-user/issues/80) +- [NEW] 支持恢复已删除用户 [#15](https://github.com/TencentBlueKing/bk-user/issues/15) +- [FIX] 增大默认的 CPU 限制,保证容器正常启动 + + + ## [Version: 2.3.0] - 2021-10-22 diff --git a/pyproject.toml b/pyproject.toml index 4f56532cf..2ddaf64e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "蓝鲸用户管理" -version = "2.3.0" +version = "2.3.1" description = "project description file for ci" authors = ["IMBlues "] diff --git a/readme.md b/readme.md index df895bce6..af11ac03d 100644 --- a/readme.md +++ b/readme.md @@ -3,7 +3,7 @@ --- [![license](https://img.shields.io/badge/license-mit-green.svg?style=flat)](https://github.com/TencentBlueKing/bk-user/blob/master/LICENSE) -[![Release Version](https://img.shields.io/badge/bk--user-2.3.0-green)](https://github.com/TencentBlueKing/bk-user/releases) +[![Release Version](https://img.shields.io/badge/bk--user-2.3.1-green)](https://github.com/TencentBlueKing/bk-user/releases) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-green.svg)](https://github.com/TencentBlueKing/bk-user/pulls) 简体中文 | [English](readme_en.md) diff --git a/readme_en.md b/readme_en.md index 49c419923..8866b56c4 100644 --- a/readme_en.md +++ b/readme_en.md @@ -3,7 +3,7 @@ --- [![license](https://img.shields.io/badge/license-mit-green.svg?style=flat)](https://github.com/TencentBlueKing/bk-user/blob/master/LICENSE) -[![Release Version](https://img.shields.io/badge/bk--user-2.3.0-green)](https://github.com/TencentBlueKing/bk-user/releases) +[![Release Version](https://img.shields.io/badge/bk--user-2.3.1-green)](https://github.com/TencentBlueKing/bk-user/releases) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-green.svg)](https://github.com/TencentBlueKing/bk-user/pulls) [简体中文](readme.md) | English diff --git a/src/api/bkuser_core/audit/constants.py b/src/api/bkuser_core/audit/constants.py index 1922449a2..4073b0804 100644 --- a/src/api/bkuser_core/audit/constants.py +++ b/src/api/bkuser_core/audit/constants.py @@ -15,15 +15,7 @@ RESET_PASSWORD_VAILD_MINUTES = 3 * 60 -TOKEN_IS_OK = 0 -TOKEN_USED_CODE = 10000 -TOKEN_EXPIRED_CODE = 10001 -TOKEN_PROFILE_NOT_EXIST_CODE = 10002 -TOKEN_NOT_EXIST_CODE = 10003 - - -class LogInFailReasonEnum(AutoLowerEnum): - +class LogInFailReason(AutoLowerEnum): BAD_PASSWORD = auto() EXPIRED_PASSWORD = auto() TOO_MANY_FAILURE = auto() @@ -39,8 +31,7 @@ class LogInFailReasonEnum(AutoLowerEnum): ) -class OperationEnum(AutoLowerEnum): - +class OperationType(AutoLowerEnum): CREATE = auto() UPDATE = auto() DELETE = auto() @@ -61,3 +52,13 @@ class OperationEnum(AutoLowerEnum): (IMPORT, "导入"), (RESTORATION, "恢复"), ) + + +class OperationStatus(AutoLowerEnum): + SUCCEED = auto() + FAILED = auto() + + _choices_labels = ( + (SUCCEED, "成功"), + (FAILED, "失败"), + ) diff --git a/src/api/bkuser_core/audit/handlers.py b/src/api/bkuser_core/audit/handlers.py index 36c66c2f7..c15093696 100644 --- a/src/api/bkuser_core/audit/handlers.py +++ b/src/api/bkuser_core/audit/handlers.py @@ -8,28 +8,46 @@ 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 TYPE_CHECKING + +from bkuser_core.audit.constants import OperationType from bkuser_core.audit.utils import create_general_log, create_profile_log -from bkuser_core.profiles.signals import post_profile_create, post_profile_update +from bkuser_core.categories.signals import post_category_create +from bkuser_core.departments.signals import post_department_create +from bkuser_core.profiles.signals import post_field_create, post_profile_create, post_profile_update +from bkuser_core.user_settings.signals import post_setting_create from django.dispatch import receiver +if TYPE_CHECKING: + from bkuser_core.profiles.models import Profile + + +logger = logging.getLogger(__name__) + -@receiver(post_profile_create) @receiver(post_profile_update) -def create_audit_log(sender, profile, operator, operation_type, extra_values, **kwargs): - """Create an audit log""" - request = extra_values["request"] +def create_reset_password_log(sender, instance: "Profile", operator: str, extra_values: dict, **kwargs): + """Create an audit log for profile""" + # 当密码信息存在时,我们需要增加一条记录 + if "raw_password" in extra_values: + try: + create_profile_log( + instance, + "ResetPassword", + {"is_success": True, "password": extra_values["raw_password"]}, + extra_values["request"], + ) + except Exception: # pylint: disable=broad-except + logger.exception("failed to create reset password log") + + +@receiver([post_profile_create, post_department_create, post_category_create, post_field_create, post_setting_create]) +def create_audit_log(sender, instance: "Profile", operator: str, extra_values: dict, **kwargs): + """Create an audit log for instance""" create_general_log( operator=operator, - operate_type=operation_type, - operator_obj=profile, - request=request, + operate_type=OperationType.CREATE.value, + operator_obj=instance, + request=extra_values["request"], ) - - # 当密码信息存在时,我们需要增加一条记录, - if "raw_password" in extra_values: - create_profile_log( - profile, - "ResetPassword", - {"is_success": True, "password": extra_values["raw_password"]}, - request, - ) diff --git a/src/api/bkuser_core/audit/managers.py b/src/api/bkuser_core/audit/managers.py index a92025538..80ef95e03 100644 --- a/src/api/bkuser_core/audit/managers.py +++ b/src/api/bkuser_core/audit/managers.py @@ -12,7 +12,7 @@ from django.core.exceptions import ObjectDoesNotExist from django.db import models -from .constants import LogInFailReasonEnum +from .constants import LogInFailReason class ResetPasswordManager(models.Manager): @@ -26,8 +26,8 @@ def latest_failed_count(self) -> int: create_time = self.filter(is_success=True).latest().create_time return self.filter( is_success=False, - reason=LogInFailReasonEnum.BAD_PASSWORD.value, # type: ignore + reason=LogInFailReason.BAD_PASSWORD.value, # type: ignore create_time__gt=create_time, ).count() except ObjectDoesNotExist: - return self.filter(is_success=False, reason=LogInFailReasonEnum.BAD_PASSWORD.value).count() # type: ignore + return self.filter(is_success=False, reason=LogInFailReason.BAD_PASSWORD.value).count() # type: ignore diff --git a/src/api/bkuser_core/audit/migrations/0004_auto_20211021_1852.py b/src/api/bkuser_core/audit/migrations/0004_auto_20211021_1852.py new file mode 100644 index 000000000..5f76a6a23 --- /dev/null +++ b/src/api/bkuser_core/audit/migrations/0004_auto_20211021_1852.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.5 on 2021-10-21 10:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('audit', '0003_auto_20210516_1652'), + ] + + operations = [ + migrations.AddField( + model_name='generallog', + name='status', + field=models.CharField(choices=[('succeed', '成功'), ('failed', '失败')], default='successed', max_length=16, verbose_name='状态'), + ), + ] diff --git a/src/api/bkuser_core/audit/models.py b/src/api/bkuser_core/audit/models.py index 7bb245e9b..768fa1c1e 100644 --- a/src/api/bkuser_core/audit/models.py +++ b/src/api/bkuser_core/audit/models.py @@ -12,14 +12,13 @@ from dataclasses import dataclass from typing import Optional +from bkuser_core.audit.constants import LogInFailReason, OperationStatus +from bkuser_core.audit.managers import LogInManager, ResetPasswordManager from bkuser_core.common.fields import EncryptField from bkuser_core.common.models import TimestampedModel from django.db import models from jsonfield import JSONField -from .constants import LogInFailReasonEnum -from .managers import LogInManager, ResetPasswordManager - @dataclass class AuditObjMetaInfo: @@ -60,6 +59,8 @@ class Meta: class GeneralLog(Log): """通用操作日志""" + status = models.CharField("状态", max_length=16, choices=OperationStatus.get_choices()) + class ApiRequest(Log): """API 请求日志""" @@ -77,7 +78,7 @@ class LogIn(ProfileRelatedLog): reason = models.CharField( "登陆失败原因", max_length=32, - choices=LogInFailReasonEnum.get_choices(), + choices=LogInFailReason.get_choices(), null=True, blank=True, ) diff --git a/src/api/bkuser_core/audit/serializers.py b/src/api/bkuser_core/audit/serializers.py index ff4057937..eaf0c24e6 100644 --- a/src/api/bkuser_core/audit/serializers.py +++ b/src/api/bkuser_core/audit/serializers.py @@ -18,6 +18,7 @@ class GeneralLogSerializer(CustomFieldsMixin, serializers.Serializer): extra_value = serializers.JSONField(help_text=_("额外信息")) operator = serializers.CharField(help_text=_("操作者")) create_time = serializers.DateTimeField(help_text=_("创建时间")) + status = serializers.CharField(help_text=_("状态")) class LoginLogSerializer(CustomFieldsMixin, serializers.Serializer): diff --git a/src/api/bkuser_core/audit/utils.py b/src/api/bkuser_core/audit/utils.py index 28b6866d0..e27921fa0 100644 --- a/src/api/bkuser_core/audit/utils.py +++ b/src/api/bkuser_core/audit/utils.py @@ -8,14 +8,16 @@ 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 functools import logging from typing import TYPE_CHECKING, Any, Dict, Optional +from bkuser_core.audit import models as log_models_module +from bkuser_core.audit.constants import OperationStatus, OperationType +from bkuser_core.audit.models import GeneralLog, ProfileRelatedLog +from bkuser_core.common.error_codes import CoreAPIError from django.conf import settings - -from . import models -from .constants import OperationEnum -from .models import GeneralLog, ProfileRelatedLog +from django.utils.translation import ugettext_lazy as _ if TYPE_CHECKING: from bkuser_core.profiles.models import Profile @@ -46,6 +48,7 @@ def create_general_log( operator: str, operate_type: str, operator_obj: Any, + status: str = OperationStatus.SUCCEED.value, extra_info: Dict = None, request=None, ) -> Optional[GeneralLog]: @@ -56,7 +59,7 @@ def create_general_log( logger.exception("Object<%s> should add to_audit_info() method", operator_obj.__class__) return None - if not OperationEnum.has_value(operate_type): + if not OperationType.has_value(operate_type): logger.exception("operate type<%s> unknown", operate_type) return None @@ -77,7 +80,7 @@ def create_general_log( operator_obj, operator_obj.__class__.__name__, ) - return GeneralLog.objects.create(operator=operator, extra_value=extra_value) + return GeneralLog.objects.create(operator=operator, extra_value=extra_value, status=status) def create_profile_log( @@ -88,8 +91,52 @@ def create_profile_log( create_params.update({"extra_value": {"client_ip": get_client_ip(request)}}) try: - return getattr(models, operation).objects.create(profile=profile, **create_params) + return getattr(log_models_module, operation).objects.create(profile=profile, **create_params) except AttributeError: raise ValueError("unknown operation for profile log") except Exception: raise ValueError("operation is not a profile log type") + + +def audit_general_log(operate_type: str): + """定义捕获异常的审计日志装饰器""" + + if operate_type == OperationType.CREATE.value: + raise ValueError("audit_general_log decoration does not support create views") + + def catch_exc(func): + @functools.wraps(func) + def _catch_exc(self, request, *args, **kwargs): + _params = { + "operator": request.operator, + "operate_type": operate_type, + "request": request, + "operator_obj": self.get_object(), + } + try: + _result = func(self, request, *args, **kwargs) + except Exception as e: + if operate_type == OperationType.UPDATE.value: + _params["operator_obj"] = self.get_object() + + if isinstance(e, CoreAPIError): + failed_info = f"{e.message}" + else: + failed_info = _("未知异常,请查阅日志了解详情") + + create_general_log( + **_params, + status=OperationStatus.FAILED.value, + extra_info={"failed_info": failed_info}, + ) + raise + else: + if operate_type == OperationType.UPDATE.value: + _params["operator_obj"] = self.get_object() + + create_general_log(**_params) + return _result + + return _catch_exc + + return catch_exc diff --git a/src/api/bkuser_core/bkiam/constants.py b/src/api/bkuser_core/bkiam/constants.py index 1a5750218..88706087f 100644 --- a/src/api/bkuser_core/bkiam/constants.py +++ b/src/api/bkuser_core/bkiam/constants.py @@ -13,7 +13,7 @@ import regex from bkuser_core.categories.constants import CategoryType -from bkuser_core.categories.models import ProfileCategory +from bkuser_core.categories.models import ProfileCategory, SyncTask from bkuser_core.common.enum import AutoLowerEnum from bkuser_core.departments.models import Department from bkuser_core.profiles.models import DynamicFieldInfo, Profile @@ -125,6 +125,7 @@ class ResourceType(AutoLowerEnum): CATEGORY = auto() DEPARTMENT = auto() PROFILE = auto() + SYNCTASK = auto() @classmethod def get_type_name(cls, resource_type: "ResourceType") -> str: @@ -142,6 +143,7 @@ def get_by_model(cls, instance) -> "ResourceType": ProfileCategory: cls.CATEGORY, DynamicFieldInfo: cls.FIELD, Profile: cls.PROFILE, + SyncTask: cls.SYNCTASK, }[type(instance)] @classmethod @@ -196,6 +198,7 @@ def parse_department_path(data): cls.CATEGORY: {"category.id": "id"}, cls.FIELD: {"field.id": "name"}, cls.PROFILE: {}, + cls.SYNCTASK: {"category.id": "category_id"}, } return _map[resource_type] diff --git a/src/api/bkuser_core/categories/handlers.py b/src/api/bkuser_core/categories/handlers.py index f73beebf0..cfc744aa6 100644 --- a/src/api/bkuser_core/categories/handlers.py +++ b/src/api/bkuser_core/categories/handlers.py @@ -13,6 +13,7 @@ from bkuser_core.bkiam.constants import IAMAction, ResourceType from bkuser_core.bkiam.helper import IAMHelper from bkuser_core.categories.signals import post_category_create +from django.conf import settings from django.dispatch import receiver from .plugins.ldap.handlers import create_sync_tasks, delete_sync_tasks, update_sync_tasks # noqa @@ -22,12 +23,16 @@ @receiver(post_category_create) -def create_creator_actions(sender, category, **kwargs): +def create_creator_actions(sender, instance, **kwargs): """请求权限中心,创建新建关联权限记录""" - logger.info("going to create resource_creator_action for Category<%s>", category.id) + if not settings.ENABLE_IAM: + logger.info("skip creation of resource_creator_action (category related) due to ENABLE_IAM is false") + return + + logger.info("going to create resource_creator_action for Category<%s>", instance.id) helper = IAMHelper() try: - helper.create_creator_actions(kwargs["creator"], category) + helper.create_creator_actions(kwargs["creator"], instance) except Exception: # pylint: disable=broad-except logger.exception("failed to create resource_creator_action (category related)") @@ -35,7 +40,7 @@ def create_creator_actions(sender, category, **kwargs): try: helper.create_auth_by_ancestor( username=kwargs["creator"], - ancestor=category, + ancestor=instance, target_type=ResourceType.DEPARTMENT.value, action_ids=[IAMAction.MANAGE_DEPARTMENT, IAMAction.VIEW_DEPARTMENT], ) diff --git a/src/api/bkuser_core/categories/plugins/ldap/handlers.py b/src/api/bkuser_core/categories/plugins/ldap/handlers.py index 1fc37d51a..645355ff6 100644 --- a/src/api/bkuser_core/categories/plugins/ldap/handlers.py +++ b/src/api/bkuser_core/categories/plugins/ldap/handlers.py @@ -9,6 +9,7 @@ specific language governing permissions and limitations under the License. """ import logging +from typing import TYPE_CHECKING from bkuser_core.categories.constants import CategoryType from bkuser_core.categories.loader import get_plugin_by_category @@ -18,46 +19,51 @@ update_periodic_sync_task, ) from bkuser_core.categories.signals import post_category_create, post_category_delete -from bkuser_core.user_settings.signals import post_setting_create_or_update +from bkuser_core.user_settings.signals import post_setting_create, post_setting_update from django.dispatch import receiver +if TYPE_CHECKING: + from bkuser_core.categories.models import ProfileCategory + from bkuser_core.user_settings.models import Setting + logger = logging.getLogger(__name__) @receiver(post_category_create) -def create_sync_tasks(sender, category, creator: str, **kwargs): - if category.type not in [CategoryType.LDAP.value, CategoryType.MAD.value]: +def create_sync_tasks(sender, instance: "ProfileCategory", operator: str, **kwargs): + if instance.type not in [CategoryType.LDAP.value, CategoryType.MAD.value]: return - logger.info("going to add periodic task for Category<%s>", category.id) + logger.info("going to add periodic task for Category<%s>", instance.id) make_periodic_sync_task( - category_id=category.id, - operator=creator, - interval_seconds=get_plugin_by_category(category).extra_config["default_sync_period"], + category_id=instance.id, + operator=operator, + interval_seconds=get_plugin_by_category(instance).extra_config["default_sync_period"], ) @receiver(post_category_delete) -def delete_sync_tasks(sender, category, **kwargs): - if category.type not in [CategoryType.LDAP.value, CategoryType.MAD.value]: +def delete_sync_tasks(sender, instance: "ProfileCategory", **kwargs): + if instance.type not in [CategoryType.LDAP.value, CategoryType.MAD.value]: return - logger.info("going to delete periodic task for Category<%s>", category.id) - delete_periodic_sync_task(category.id) + logger.info("going to delete periodic task for Category<%s>", instance.id) + delete_periodic_sync_task(instance.id) -@receiver(post_setting_create_or_update) -def update_sync_tasks(sender, setting, operator: str, **kwargs): - if setting.category.type not in [CategoryType.LDAP.value, CategoryType.MAD.value]: +@receiver(post_setting_update) +@receiver(post_setting_create) +def update_sync_tasks(sender, instance: "Setting", operator: str, **kwargs): + if instance.category.type not in [CategoryType.LDAP.value, CategoryType.MAD.value]: return - if not setting.meta.key == "pull_cycle": + if not instance.meta.key == "pull_cycle": return - cycle_value = int(setting.value) - category_config = get_plugin_by_category(setting.category) + cycle_value = int(instance.value) + category_config = get_plugin_by_category(instance.category) if cycle_value <= 0: - delete_periodic_sync_task(category_id=setting.category_id) + delete_periodic_sync_task(category_id=instance.category_id) return elif cycle_value < category_config.extra_config["min_sync_period"]: @@ -66,10 +72,10 @@ def update_sync_tasks(sender, setting, operator: str, **kwargs): # 尝试更新周期任务周期 logger.info( "going to update category<%s> sync interval to %s", - setting.category_id, + instance.category_id, cycle_value, ) try: - update_periodic_sync_task(category_id=setting.category_id, operator=operator, interval_seconds=cycle_value) + update_periodic_sync_task(category_id=instance.category_id, operator=operator, interval_seconds=cycle_value) except Exception: # pylint: disable=broad-except logger.exception("failed to update periodic task schedule") diff --git a/src/api/bkuser_core/categories/plugins/local/handlers.py b/src/api/bkuser_core/categories/plugins/local/handlers.py index 04499be94..d5c5f6345 100644 --- a/src/api/bkuser_core/categories/plugins/local/handlers.py +++ b/src/api/bkuser_core/categories/plugins/local/handlers.py @@ -9,18 +9,22 @@ specific language governing permissions and limitations under the License. """ import logging +from typing import TYPE_CHECKING from bkuser_core.categories.constants import CategoryType from bkuser_core.categories.signals import post_category_create from django.dispatch import receiver +if TYPE_CHECKING: + from bkuser_core.categories.models import ProfileCategory + logger = logging.getLogger(__name__) @receiver(post_category_create) -def make_local_default_settings(sender, category, **kwargs): - if category.type not in [CategoryType.LOCAL.value]: +def make_local_default_settings(sender, instance: "ProfileCategory", **kwargs): + if instance.type not in [CategoryType.LOCAL.value]: return - logger.info("going to make default settings for Category<%s>", category.id) - category.make_default_settings() + logger.info("going to make default settings for Category<%s>", instance.id) + instance.make_default_settings() diff --git a/src/api/bkuser_core/categories/signals.py b/src/api/bkuser_core/categories/signals.py index 2d31cb9c0..77f5a1257 100644 --- a/src/api/bkuser_core/categories/signals.py +++ b/src/api/bkuser_core/categories/signals.py @@ -10,5 +10,5 @@ """ import django -post_category_create = django.dispatch.Signal(providing_args=["category", "creator"]) -post_category_delete = django.dispatch.Signal(providing_args=["category", "operator"]) +post_category_create = django.dispatch.Signal(providing_args=["instance", "operator", "extra_values"]) +post_category_delete = django.dispatch.Signal(providing_args=["instance", "operator", "extra_values"]) diff --git a/src/api/bkuser_core/categories/views.py b/src/api/bkuser_core/categories/views.py index aa4afca72..ec93b44b4 100644 --- a/src/api/bkuser_core/categories/views.py +++ b/src/api/bkuser_core/categories/views.py @@ -11,8 +11,8 @@ import logging from typing import List -from bkuser_core.audit.constants import OperationEnum -from bkuser_core.audit.utils import create_general_log +from bkuser_core.audit.constants import OperationType +from bkuser_core.audit.utils import audit_general_log from bkuser_core.bkiam.permissions import IAMAction, IAMHelper, IAMPermissionExtraInfo, need_iam from bkuser_core.categories.constants import CategoryType, SyncTaskType from bkuser_core.categories.exceptions import ExistsSyncingTaskError, FetchDataFromRemoteFailed @@ -37,7 +37,6 @@ from bkuser_core.common.serializers import EmptySerializer from bkuser_core.common.viewset import AdvancedListAPIView, AdvancedModelViewSet, AdvancedSearchFilter from django.utils.decorators import method_decorator -from django.utils.module_loading import import_string from drf_yasg.utils import swagger_auto_schema from rest_framework import filters, status from rest_framework.decorators import action @@ -118,15 +117,9 @@ def create(self, request, *args, **kwargs): max_order = ProfileCategory.objects.get_max_order() instance.order = max_order + 1 instance.save(update_fields=["order"]) - - post_category_create.send(sender=self, category=instance, creator=request.operator) - create_general_log( - operator=request.operator, - operate_type=OperationEnum.CREATE.value, - operator_obj=instance, - request=request, + post_category_create.send_robust( + sender=self, instance=instance, operator=request.operator, extra_values={"request": request} ) - return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) def get_serializer(self, *args, **kwargs): @@ -135,6 +128,7 @@ def get_serializer(self, *args, **kwargs): else: return self.serializer_class(*args, **kwargs) + @audit_general_log(operate_type=OperationType.UPDATE.value) @method_decorator(clear_cache_if_succeed) def update(self, request, *args, **kwargs): """ @@ -161,13 +155,6 @@ def update(self, request, *args, **kwargs): # forcibly invalidate the prefetch cache on the instance. instance._prefetched_objects_cache = {} - create_general_log( - operator=request.operator, - operate_type=OperationEnum.UPDATE.value, - operator_obj=instance, - request=request, - ) - return Response(serializer.data) def destroy(self, request, *args, **kwargs): @@ -178,7 +165,7 @@ def destroy(self, request, *args, **kwargs): if instance.default: raise error_codes.CANNOT_DELETE_DEFAULT_CATEGORY - post_category_delete.send(sender=self, category=instance, operator=request.operator) + post_category_delete.send_robust(sender=self, instance=instance, operator=request.operator) return super().destroy(request, *args, **kwargs) @swagger_auto_schema( @@ -197,8 +184,7 @@ def test_connection(self, request, lookup_value): raise error_codes.TEST_CONNECTION_UNSUPPORTED try: - category_config = get_plugin_by_category(instance) - client_class = import_string(category_config.extra_config["ldap_client"]) + syncer_cls = get_plugin_by_category(instance).syncer_cls except Exception: logger.exception( "category<%s-%s-%s> load ldap client failed", @@ -209,7 +195,7 @@ def test_connection(self, request, lookup_value): raise error_codes.LOAD_LDAP_CLIENT_FAILED try: - client_class.initialize(**serializer.validated_data) + syncer_cls(instance.id).fetcher.client.initialize(**serializer.validated_data) except Exception: logger.exception("failed to test initialize category<%s>", instance.id) raise error_codes.TEST_CONNECTION_FAILED @@ -257,6 +243,7 @@ def test_fetch_data(self, request, lookup_value): return Response() + @audit_general_log(operate_type=OperationType.SYNC.value) @method_decorator(clear_cache_if_succeed) @swagger_auto_schema(request_body=CategorySyncSerializer, responses={"200": CategorySyncResponseSLZ()}) def sync(self, request, lookup_value): @@ -287,12 +274,6 @@ def sync(self, request, lookup_value): logger.exception("failed to sync data") raise error_codes.SYNC_DATA_FAILED - create_general_log( - operator=request.operator, - operate_type=OperationEnum.SYNC.value, - operator_obj=instance, - request=request, - ) return Response({"task_id": task_id}) @@ -303,6 +284,7 @@ class CategoryFileViewSet(AdvancedModelViewSet, AdvancedListAPIView): lookup_field = "id" ordering = ["-create_time"] + @audit_general_log(operate_type=OperationType.IMPORT.value) @method_decorator(clear_cache_if_succeed) @swagger_auto_schema(request_body=CategorySyncSerializer, responses={"200": EmptySerializer()}) def import_data_file(self, request, lookup_value): @@ -333,12 +315,6 @@ def import_data_file(self, request, lookup_value): logger.exception("failed to sync data") raise error_codes.SYNC_DATA_FAILED.format(str(e), replace=True) - create_general_log( - operator=request.operator, - operate_type=OperationEnum.IMPORT.value, - operator_obj=instance, - request=request, - ) return Response() diff --git a/src/api/bkuser_core/common/error_codes.py b/src/api/bkuser_core/common/error_codes.py index 6e5b161e4..8243472aa 100644 --- a/src/api/bkuser_core/common/error_codes.py +++ b/src/api/bkuser_core/common/error_codes.py @@ -93,7 +93,9 @@ def __getattr__(self, code_name): # 通用 ErrorCode("FIELDS_NOT_SUPPORTED_YET", _("存在不支持动态返回的字段")), ErrorCode("QUERY_PARAMS_ERROR", _("查询参数错误,请检查")), - ErrorCode("RESOURCE_ALREADY_ENABLED", _("资源数据未被删除"), status_code=HTTP_400_BAD_REQUEST), + ErrorCode("USERNAME_MISSING", _("用户名信息缺失")), + ErrorCode("RESOURCE_ALREADY_ENABLED", _("资源数据未被删除")), + ErrorCode("RESOURCE_RESTORATION_FAILED", _("资源恢复失败")), # 登陆相关 ErrorCode("USER_DOES_NOT_EXIST", _("账号不存在"), 3210010), ErrorCode("USERNAME_FORMAT_ERROR", _("账户名格式错误"), 3210012), diff --git a/src/api/bkuser_core/common/middlewares.py b/src/api/bkuser_core/common/middlewares.py index 77b1bbc46..b34a5fb17 100644 --- a/src/api/bkuser_core/common/middlewares.py +++ b/src/api/bkuser_core/common/middlewares.py @@ -9,6 +9,8 @@ specific language governing permissions and limitations under the License. """ +import json + from django.utils.deprecation import MiddlewareMixin from .http import force_response_ee_format, force_response_raw_format, should_use_raw_response @@ -22,6 +24,18 @@ def process_request(self, request): if request.method == "POST" and self.METHOD_OVERRIDE_HEADER in request.META: request.method = request.META[self.METHOD_OVERRIDE_HEADER] + if request.body: + request_get_params = request.GET + + original_mutable = request_get_params._mutable + request_get_params._mutable = True + + request_post_body = json.loads(request.body) + request_get_params.update(request_post_body) + + # 恢复初始的_mutable属性 + request_get_params._mutable = original_mutable + class DynamicResponseFormatMiddleware: """根据动态修改返回值格式 diff --git a/src/api/bkuser_core/common/viewset.py b/src/api/bkuser_core/common/viewset.py index 108a544cf..c4e353950 100644 --- a/src/api/bkuser_core/common/viewset.py +++ b/src/api/bkuser_core/common/viewset.py @@ -14,8 +14,8 @@ from operator import or_ from typing import Any, Dict, List, Optional -from bkuser_core.audit.constants import OperationEnum -from bkuser_core.audit.utils import create_general_log +from bkuser_core.audit.constants import OperationType +from bkuser_core.audit.utils import audit_general_log, create_general_log from bkuser_core.bkiam.exceptions import IAMPermissionDenied from bkuser_core.bkiam.filters import IAMFilter from bkuser_core.bkiam.permissions import IAMPermission, IAMPermissionExtraInfo @@ -245,7 +245,7 @@ def check_object_permissions(self, request, obj): if not permission.has_object_permission(request, self, obj): self.permission_denied(request, message=getattr(permission, "message", None), obj=obj) - def permission_denied(self, request, message=None, obj=None): + def permission_denied(self, request, message=None, obj=None, **kwargs): """针对 IAM 注入相关信息""" raise IAMPermissionDenied( detail=message, @@ -284,55 +284,34 @@ def retrieve(self, request, *args, **kwargs): status=status.HTTP_200_OK, ) + @audit_general_log(operate_type=OperationType.UPDATE.value) @method_decorator(clear_cache_if_succeed) @swagger_auto_schema(query_serializer=AdvancedRetrieveSerialzier()) def update(self, request, *args, **kwargs): """更新对象""" - instance = self.get_object() - result = super().update(request, *args, **kwargs) - - create_general_log( - operator=request.operator, - operate_type=OperationEnum.UPDATE.value, - operator_obj=instance, - request=request, - ) - return result + return super().update(request, *args, **kwargs) + @audit_general_log(operate_type=OperationType.DELETE.value) @method_decorator(clear_cache_if_succeed) @swagger_auto_schema(query_serializer=AdvancedRetrieveSerialzier()) def destroy(self, request, *args, **kwargs): """删除对象""" - instance = self.get_object() - create_general_log( - operator=request.operator, - operate_type=OperationEnum.DELETE.value, - operator_obj=instance, - request=request, - ) - return super().destroy(request, *args, **kwargs) + @audit_general_log(operate_type=OperationType.RESTORATION.value) @method_decorator(clear_cache_if_succeed) @swagger_auto_schema(query_serializer=AdvancedRetrieveSerialzier(), request_body=EmptySerializer) def restoration(self, request, lookup_value): """软删除对象恢复""" + # TODO: auto support include_disabled=True instance = self.get_object() if instance.enabled: raise error_codes.RESOURCE_ALREADY_ENABLED try: instance.enable() - except Exception as why: - # TODO: 基于 issue71 更新操作日志 - logger.exception("failed to restoration instance: %s, error: %s", instance, why) - else: - create_general_log( - operator=request.operator, - operate_type=OperationEnum.RESTORATION.value, - operator_obj=instance, - request=request, - extra_info={"action": f"restoration {instance._meta.model_name}.{self.lookup_field}-{lookup_value}"}, - ) + except Exception: + logger.exception("failed to restoration instance: %s", instance) + raise error_codes.RESOURCE_RESTORATION_FAILED return Response() @@ -418,7 +397,7 @@ def multiple_update(self, request): else: create_general_log( operator=request.operator, - operate_type=OperationEnum.UPDATE.value, + operate_type=OperationType.UPDATE.value, operator_obj=instance, request=request, ) @@ -447,7 +426,7 @@ def multiple_delete(self, request): instance = self.queryset.get(pk=obj["id"]) create_general_log( operator=request.operator, - operate_type=OperationEnum.DELETE.value, + operate_type=OperationType.DELETE.value, operator_obj=instance, request=request, ) diff --git a/src/api/bkuser_core/config/common/django_basic.py b/src/api/bkuser_core/config/common/django_basic.py index c51a0e2f4..8d6e8fbb8 100644 --- a/src/api/bkuser_core/config/common/django_basic.py +++ b/src/api/bkuser_core/config/common/django_basic.py @@ -38,6 +38,7 @@ MIDDLEWARE = [ + "django_prometheus.middleware.PrometheusBeforeMiddleware", "django.middleware.common.CommonMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.csrf.CsrfViewMiddleware", @@ -49,6 +50,7 @@ "bkuser_global.middlewares.TimezoneMiddleware", "bkuser_core.common.middlewares.MethodOverrideMiddleware", "bkuser_core.common.middlewares.DynamicResponseFormatMiddleware", + "django_prometheus.middleware.PrometheusAfterMiddleware", ] INSTALLED_APPS = [ @@ -63,6 +65,7 @@ "rest_framework", "mptt", "django_celery_beat", + "django_prometheus", # core API "bkuser_core.apis", "bkuser_core.monitoring", diff --git a/src/api/bkuser_core/config/common/platform.py b/src/api/bkuser_core/config/common/platform.py index 56b9501a0..4cba72eed 100644 --- a/src/api/bkuser_core/config/common/platform.py +++ b/src/api/bkuser_core/config/common/platform.py @@ -60,7 +60,7 @@ # 权限中心相关配置 # ============================================================================== # 默认启用,禁用时会跳过权限校验步骤 -ENABLE_IAM = True +ENABLE_IAM = env.bool("ENABLE_IAM", default=True) def get_iam_config(app_id: str, app_token: str) -> dict: diff --git a/src/api/bkuser_core/departments/serializers.py b/src/api/bkuser_core/departments/serializers.py index 4a3729ea5..f26d6ab34 100644 --- a/src/api/bkuser_core/departments/serializers.py +++ b/src/api/bkuser_core/departments/serializers.py @@ -127,6 +127,13 @@ class DepartmentProfileEdgesSLZ(serializers.Serializer): # =============================================================================== +class DepartmentUpdateSerializer(serializers.Serializer): + name = serializers.CharField(required=False) + order = serializers.IntegerField(required=False) + extras = serializers.JSONField(required=False) + enabled = serializers.BooleanField(required=False) + + class DepartmentAddProfilesSerializer(serializers.Serializer): profile_id_list = serializers.ListField(child=serializers.IntegerField()) diff --git a/src/api/bkuser_core/departments/signals.py b/src/api/bkuser_core/departments/signals.py index bb3550cab..3571e16e0 100644 --- a/src/api/bkuser_core/departments/signals.py +++ b/src/api/bkuser_core/departments/signals.py @@ -10,12 +10,6 @@ """ import django -post_department_create = django.dispatch.Signal( - providing_args=["department", "operator", "operation_type", "extra_values"] -) -post_department_update = django.dispatch.Signal( - providing_args=["department", "operator", "operation_type", "extra_values"] -) -post_department_delete = django.dispatch.Signal( - providing_args=["department", "operator", "operation_type", "extra_values"] -) +post_department_create = django.dispatch.Signal(providing_args=["instance", "operator", "extra_values"]) +post_department_update = django.dispatch.Signal(providing_args=["instance", "operator", "extra_values"]) +post_department_delete = django.dispatch.Signal(providing_args=["instance", "operator", "extra_values"]) diff --git a/src/api/bkuser_core/departments/urls.py b/src/api/bkuser_core/departments/urls.py index ce5aa3007..14c81ca61 100644 --- a/src/api/bkuser_core/departments/urls.py +++ b/src/api/bkuser_core/departments/urls.py @@ -13,7 +13,7 @@ from . import views -PVAR_DEPARTMENT_ID = r"(?P<%s>[\w\-]+)" % LOOKUP_FIELD_NAME +PVAR_DEPARTMENT_ID = r"(?P<%s>[\w\-\.]+)" % LOOKUP_FIELD_NAME urlpatterns = [ url( diff --git a/src/api/bkuser_core/departments/views.py b/src/api/bkuser_core/departments/views.py index 60f7d63e2..e5fdb8af0 100644 --- a/src/api/bkuser_core/departments/views.py +++ b/src/api/bkuser_core/departments/views.py @@ -10,8 +10,8 @@ """ from typing import Type -from bkuser_core.audit.constants import OperationEnum -from bkuser_core.audit.utils import create_general_log +from bkuser_core.audit.constants import OperationType +from bkuser_core.audit.utils import audit_general_log from bkuser_core.bkiam.exceptions import IAMPermissionDenied from bkuser_core.bkiam.permissions import IAMPermissionExtraInfo from bkuser_core.bkiam.utils import need_iam @@ -25,8 +25,12 @@ AdvancedModelViewSet, AdvancedSearchFilter, ) +from bkuser_core.departments import serializers as local_serializers +from bkuser_core.departments.models import Department, DepartmentThroughModel +from bkuser_core.departments.signals import post_department_create from bkuser_core.profiles.models import DynamicFieldInfo, Profile from bkuser_core.profiles.serializers import ProfileMinimalSerializer, ProfileSerializer, RapidProfileSerializer +from bkuser_core.profiles.utils import force_use_raw_username from django.conf import settings from django.utils.decorators import method_decorator from django.utils.translation import ugettext_lazy as _ @@ -36,10 +40,6 @@ from rest_framework.response import Response from rest_framework.serializers import Serializer -from ..profiles.utils import force_use_raw_username -from . import serializers as local_serializers -from .models import Department, DepartmentThroughModel - class DepartmentViewSet(AdvancedModelViewSet, AdvancedListAPIView): queryset = Department.objects.filter() @@ -152,6 +152,7 @@ def get_profiles(self, request, *args, **kwargs): # https://docs.djangoproject.com/en/3.2/ref/models/querysets/#values return Response(data=list(profiles.only(*serializer_fields).values(*serializer_fields))) + @audit_general_log(operate_type=OperationType.UPDATE.value) @method_decorator(clear_cache_if_succeed) @swagger_auto_schema( request_body=local_serializers.DepartmentAddProfilesSerializer, @@ -168,13 +169,6 @@ def add_profiles(self, request, *args, **kwargs): for profile in profiles: instance.add_profile(profile) - # 审计记录 - create_general_log( - operator=request.operator, - operate_type=OperationEnum.UPDATE.value, - operator_obj=instance, - request=request, - ) return Response(data=ProfileMinimalSerializer(profiles, many=True).data) @method_decorator(clear_cache_if_succeed) @@ -222,18 +216,25 @@ def create(self, request, *args, **kwargs): except Department.DoesNotExist: instance = serializer.save() - # 审计记录 - create_general_log( - operator=request.operator, - operate_type=OperationEnum.CREATE.value, - operator_obj=instance, - request=request, + post_department_create.send( + sender=self, instance=instance, operator=request.operator, extra_values={"request": request} ) return Response(self.serializer_class(instance).data, status=status.HTTP_201_CREATED) + @swagger_auto_schema( + request_body=local_serializers.DepartmentUpdateSerializer(), + responses={200: local_serializers.DepartmentSerializer()}, + ) + def partial_update(self, request, *args, **kwargs): + return super().partial_update(request, *args, **kwargs) + + @swagger_auto_schema( + request_body=local_serializers.DepartmentUpdateSerializer(), + responses={200: local_serializers.DepartmentSerializer()}, + ) def update(self, request, *args, **kwargs): """更新部门""" - serializer = self.serializer_class(data=request.data) + serializer = local_serializers.DepartmentUpdateSerializer(data=request.data) serializer.is_valid(raise_exception=True) validated_data = serializer.validated_data instance = self.get_object() @@ -266,6 +267,8 @@ def list_tops(self, request, *args, **kwargs): descendants = Department.tree_objects.get_queryset_descendants(queryset=queryset, include_self=False) queryset = queryset.exclude(id__in=descendants.values_list("id", flat=True)) + # 为了支持 include_disabled 参数,我们默认在 queryset 中去掉了该参数,这里补上 + queryset = queryset.filter(enabled=True) if not queryset: raise IAMPermissionDenied( detail=_("您没有该操作的权限,请在权限中心申请"), diff --git a/src/api/bkuser_core/profiles/handlers.py b/src/api/bkuser_core/profiles/handlers.py index 305e6cc03..bd2bb6ae9 100644 --- a/src/api/bkuser_core/profiles/handlers.py +++ b/src/api/bkuser_core/profiles/handlers.py @@ -9,8 +9,8 @@ specific language governing permissions and limitations under the License. """ import logging +from typing import TYPE_CHECKING -from bkuser_core.audit.constants import OperationEnum from bkuser_core.common.error_codes import error_codes from bkuser_core.profiles.signals import post_profile_create, post_profile_update from django.dispatch import receiver @@ -18,20 +18,33 @@ from .exceptions import ProfileEmailEmpty from .tasks import send_password_by_email +if TYPE_CHECKING: + from bkuser_core.profiles.models import Profile logger = logging.getLogger(__name__) -@receiver(post_profile_create) @receiver(post_profile_update) -def notify_by_email(sender, profile, operator, operation_type, extra_values, **kwargs): +def notify_reset_email(sender, instance: "Profile", operator: str, extra_values: dict, **kwargs): + """Notify the result of creating or updating password""" + if not extra_values.get("should_notify"): + return + + try: + send_password_by_email.delay(instance.id, raw_password=extra_values["raw_password"], init=False) + except ProfileEmailEmpty: + raise error_codes.EMAIL_NOT_PROVIDED + except Exception: # pylint: disable=broad-except + logger.exception("failed to send password via email") + + +@receiver(post_profile_create) +def notify_init_password(sender, instance: "Profile", operator: str, extra_values: dict, **kwargs): """Notify the result of creating profile""" if not extra_values.get("should_notify"): return - init = operation_type == OperationEnum.CREATE.value try: - logger.info("going to notify password via email") - send_password_by_email.delay(profile.id, raw_password=extra_values["raw_password"], init=init) + send_password_by_email.delay(instance.id, raw_password=extra_values["raw_password"], init=True) except ProfileEmailEmpty: raise error_codes.EMAIL_NOT_PROVIDED except Exception: # pylint: disable=broad-except diff --git a/src/api/bkuser_core/profiles/models.py b/src/api/bkuser_core/profiles/models.py index da50245c8..d57f9724b 100644 --- a/src/api/bkuser_core/profiles/models.py +++ b/src/api/bkuser_core/profiles/models.py @@ -9,9 +9,10 @@ specific language governing permissions and limitations under the License. """ import datetime +from typing import Optional import jsonfield -from bkuser_core.audit.constants import LogInFailReasonEnum +from bkuser_core.audit.constants import LogInFailReason from bkuser_core.audit.models import AuditObjMetaInfo from bkuser_core.common.bulk_update.manager import BulkUpdateManager from bkuser_core.common.models import TimestampedModel @@ -170,9 +171,7 @@ def bad_check_cnt(self) -> int: @property def latest_check_time(self): - return ( - self.login_set.filter(is_success=False, reason=LogInFailReasonEnum.BAD_PASSWORD.value).latest().create_time - ) + return self.login_set.filter(is_success=False, reason=LogInFailReason.BAD_PASSWORD.value).latest().create_time @property def is_superuser(self) -> bool: @@ -183,6 +182,15 @@ def latest_password_update_time(self) -> datetime.datetime: """最近一次更新密码时间""" return self.password_update_time or self.create_time + @property + def last_login_time(self) -> Optional[datetime.datetime]: + """获取用户最近一次登录时间""" + latest_logins = self.login_set.filter(is_success=True) + if latest_logins: + return latest_logins.latest().create_time + + return None + def enable(self): self.enabled = True self.status = ProfileStatus.NORMAL.value @@ -190,13 +198,9 @@ def enable(self): def delete(self, using=None, keep_parents=False): """软删除""" + # 为了保证用户恢复时拥有原来所有关系,这里只修改状态字段 self.enabled = False self.status = ProfileStatus.DELETED.value - - # 解除与其他模型的绑定关系 - self.departments.clear() - self.leader.clear() - self.save(update_fields=["enabled", "status", "update_time"]) return diff --git a/src/api/bkuser_core/profiles/serializers.py b/src/api/bkuser_core/profiles/serializers.py index 7dce20c1c..342c080f0 100644 --- a/src/api/bkuser_core/profiles/serializers.py +++ b/src/api/bkuser_core/profiles/serializers.py @@ -71,6 +71,7 @@ class ProfileSerializer(CustomFieldsModelSerializer): departments = SimpleDepartmentSerializer(many=True, required=False) extras = serializers.SerializerMethodField(required=False) leader = LeaderSerializer(many=True, required=False) + last_login_time = serializers.DateTimeField(required=False, read_only=True) def get_extras(self, obj) -> dict: """尝试从 context 中获取默认字段值""" @@ -97,6 +98,7 @@ class RapidProfileSerializer(CustomFieldsMixin, serializers.Serializer): departments = SimpleDepartmentSerializer(many=True, required=False) leader = LeaderSerializer(many=True, required=False) + last_login_time = serializers.DateTimeField(required=False, read_only=True) create_time = serializers.DateTimeField(required=False, read_only=True) update_time = serializers.DateTimeField(required=False, read_only=True) @@ -120,7 +122,7 @@ class RapidProfileSerializer(CustomFieldsMixin, serializers.Serializer): status = serializers.CharField(read_only=True) logo = serializers.CharField(read_only=True, allow_blank=True) - def get_extras(self, obj) -> dict: + def get_extras(self, obj: "Profile") -> dict: """尝试从 context 中获取默认字段值""" return get_extras(obj.extras, self.context.get("extra_defaults", {}).copy()) diff --git a/src/api/bkuser_core/profiles/signals.py b/src/api/bkuser_core/profiles/signals.py index 4c244c2bd..3b567bd53 100644 --- a/src/api/bkuser_core/profiles/signals.py +++ b/src/api/bkuser_core/profiles/signals.py @@ -10,6 +10,8 @@ """ import django -post_profile_create = django.dispatch.Signal(providing_args=["profile", "operator", "operation_type", "extra_values"]) -post_profile_update = django.dispatch.Signal(providing_args=["profile", "operator", "operation_type", "extra_values"]) -post_profile_delete = django.dispatch.Signal(providing_args=["profile", "operator", "operation_type", "extra_values"]) +post_profile_create = django.dispatch.Signal(providing_args=["instance", "operator", "extra_values"]) +post_profile_update = django.dispatch.Signal(providing_args=["instance", "operator", "extra_values"]) +post_profile_delete = django.dispatch.Signal(providing_args=["instance", "operator", "extra_values"]) + +post_field_create = django.dispatch.Signal(providing_args=["instance", "operator", "extra_values"]) diff --git a/src/api/bkuser_core/profiles/views.py b/src/api/bkuser_core/profiles/views.py index f036e340a..cc922fb3b 100644 --- a/src/api/bkuser_core/profiles/views.py +++ b/src/api/bkuser_core/profiles/views.py @@ -14,8 +14,8 @@ from collections import defaultdict from operator import or_ -from bkuser_core.audit.constants import LogInFailReasonEnum, OperationEnum -from bkuser_core.audit.utils import create_general_log, create_profile_log +from bkuser_core.audit.constants import LogInFailReason, OperationType +from bkuser_core.audit.utils import audit_general_log, create_profile_log from bkuser_core.categories.constants import CategoryType from bkuser_core.categories.loader import get_plugin_by_category from bkuser_core.categories.models import ProfileCategory @@ -30,6 +30,21 @@ ) from bkuser_core.common.viewset import AdvancedBatchOperateViewSet, AdvancedListAPIView, AdvancedModelViewSet from bkuser_core.departments import serializers as department_serializer +from bkuser_core.profiles.constants import ProfileStatus +from bkuser_core.profiles.exceptions import CountryISOCodeNotMatch, ProfileEmailEmpty +from bkuser_core.profiles.filters import ProfileSearchFilter +from bkuser_core.profiles.models import DynamicFieldInfo, LeaderThroughModel, Profile, ProfileTokenHolder +from bkuser_core.profiles.password import PasswordValidator +from bkuser_core.profiles.signals import post_field_create, post_profile_create, post_profile_update +from bkuser_core.profiles.tasks import send_password_by_email +from bkuser_core.profiles.utils import ( + align_country_iso_code, + check_former_passwords, + force_use_raw_username, + make_password_by_config, + parse_username_domain, +) +from bkuser_core.profiles.validators import validate_username from bkuser_core.user_settings.loader import ConfigProvider from django.conf import settings from django.contrib.auth.hashers import make_password @@ -48,21 +63,6 @@ from bkuser_global.utils import force_str_2_bool from . import serializers as local_serializers -from .constants import ProfileStatus -from .exceptions import CountryISOCodeNotMatch, ProfileEmailEmpty -from .filters import ProfileSearchFilter -from .models import DynamicFieldInfo, LeaderThroughModel, Profile, ProfileTokenHolder -from .password import PasswordValidator -from .signals import post_profile_create, post_profile_update -from .tasks import send_password_by_email -from .utils import ( - align_country_iso_code, - check_former_passwords, - force_use_raw_username, - make_password_by_config, - parse_username_domain, -) -from .validators import validate_username logger = logging.getLogger(__name__) @@ -73,7 +73,7 @@ class ProfileViewSet(AdvancedModelViewSet, AdvancedListAPIView): lookup_field = "username" filter_backends = [ProfileSearchFilter, filters.OrderingFilter] - relation_fields = ["departments", "leader"] + relation_fields = ["departments", "leader", "login_set"] def get_object(self): _default_lookup_field = self.lookup_field @@ -169,9 +169,12 @@ def list(self, request, *args, **kwargs): _query_slz.is_valid(True) query_data = _query_slz.validated_data - fields = query_data.get("fields", self.get_serializer().fields) + fields = query_data.get("fields", []) + if fields: + self._check_fields(fields) + else: + fields = self.get_serializer().fields self._ensure_enabled_field(request, fields=fields) - self._check_fields(fields) try: queryset = self.filter_queryset(self.get_queryset()) @@ -180,9 +183,7 @@ def list(self, request, *args, **kwargs): raise error_codes.QUERY_PARAMS_ERROR # 提前将关系表拿出来 - chosen_fields = [_f for _f in self.relation_fields if _f in fields] - if chosen_fields: - queryset = queryset.prefetch_related(*chosen_fields) + queryset = queryset.prefetch_related(*self.relation_fields) # 当用户请求数据时,判断其是否强制输出原始 username if not force_use_raw_username(request): @@ -279,14 +280,11 @@ def create(self, request, *args, **kwargs): # 善后工作 post_profile_create.send( - sender=self, - profile=instance, - operator=request.operator, - extra_values=create_summary, - operation_type=OperationEnum.CREATE.value, + sender=self, instance=instance, operator=request.operator, extra_values=create_summary ) return Response(self.serializer_class(instance).data, status=status.HTTP_201_CREATED) + @audit_general_log(OperationType.UPDATE.value) @method_decorator(clear_cache_if_succeed) def _update(self, request, partial): instance = self.get_object() @@ -352,10 +350,9 @@ def _update(self, request, partial): post_profile_update.send( sender=self, - profile=instance, + instance=instance, operator=request.operator, extra_values=update_summary, - operation_type=OperationEnum.UPDATE.value, ) return Response(self.serializer_class(instance).data) @@ -423,10 +420,9 @@ def modify_password(self, request, *args, **kwargs): } post_profile_update.send( sender=self, - profile=instance, + instance=instance, operator=request.operator, extra_values=modify_summary, - operation_type=OperationEnum.UPDATE.value, ) return Response(data=local_serializers.ProfileMinimalSerializer(instance).data) @@ -546,6 +542,7 @@ def login(self, request): except MultipleObjectsReturned: raise error_codes.PASSWORD_ERROR + time_aware_now = now() # Admin 用户只需直接判断 密码是否正确 (只有本地目录有密码配置) if not profile.is_superuser and category.type in [CategoryType.LOCAL.value]: @@ -558,7 +555,7 @@ def login(self, request): profile=profile, operation="LogIn", request=request, - params={"is_success": False, "reason": LogInFailReasonEnum.DISABLED_USER.value}, + params={"is_success": False, "reason": LogInFailReason.DISABLED_USER.value}, ) raise error_codes.USER_IS_DISABLED elif profile.status == ProfileStatus.LOCKED.value: @@ -566,7 +563,7 @@ def login(self, request): profile=profile, operation="LogIn", request=request, - params={"is_success": False, "reason": LogInFailReasonEnum.LOCKED_USER.value}, + params={"is_success": False, "reason": LogInFailReason.LOCKED_USER.value}, ) raise error_codes.USER_IS_LOCKED @@ -575,22 +572,6 @@ def login(self, request): auto_unlock_seconds = int(config_loader["auto_unlock_seconds"]) max_trail_times = int(config_loader["max_trail_times"]) - # 密码状态校验:密码过期 - time_aware_now = now() - valid_period = datetime.timedelta(days=profile.password_valid_days) - if ( - profile.password_valid_days > 0 - and ((profile.password_update_time or profile.latest_password_update_time) + valid_period) - < time_aware_now - ): - create_profile_log( - profile=profile, - operation="LogIn", - request=request, - params={"is_success": False, "reason": LogInFailReasonEnum.EXPIRED_PASSWORD.value}, - ) - raise error_codes.PASSWORD_EXPIRED - # 错误登录次数校验 if profile.bad_check_cnt >= max_trail_times > 0: from_last_check_seconds = (time_aware_now - profile.latest_check_time).total_seconds() @@ -601,7 +582,7 @@ def login(self, request): profile=profile, operation="LogIn", request=request, - params={"is_success": False, "reason": LogInFailReasonEnum.TOO_MANY_FAILURE.value}, + params={"is_success": False, "reason": LogInFailReason.TOO_MANY_FAILURE.value}, ) raise error_codes.TOO_MANY_TRY.f(f"请 {retry_after_wait}s 后再试") @@ -629,10 +610,25 @@ def login(self, request): profile=profile, operation="LogIn", request=request, - params={"is_success": False, "reason": LogInFailReasonEnum.BAD_PASSWORD.value}, + params={"is_success": False, "reason": LogInFailReason.BAD_PASSWORD.value}, ) logger.exception("check profile<%s> failed", profile.username) raise error_codes.PASSWORD_ERROR + else: + # 密码状态校验:密码过期 + valid_period = datetime.timedelta(days=profile.password_valid_days) + if ( + profile.password_valid_days > 0 + and ((profile.password_update_time or profile.latest_password_update_time) + valid_period) + < time_aware_now + ): + create_profile_log( + profile=profile, + operation="LogIn", + request=request, + params={"is_success": False, "reason": LogInFailReason.EXPIRED_PASSWORD.value}, + ) + raise error_codes.PASSWORD_EXPIRED return Response(data=local_serializers.ProfileSerializer(profile, context={"request": request}).data) @@ -742,16 +738,13 @@ def create(self, request, *args, **kwargs): instance = serializer.save() headers = self.get_success_headers(serializer.data) - - # 审计记录 - create_general_log( - operator=request.operator, - operate_type=OperationEnum.CREATE.value, - operator_obj=instance, - request=request, + post_field_create.send( + sender=self, instance=instance, operator=request.operator, extra_values={"request": request} ) + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + @audit_general_log(operate_type=OperationType.UPDATE.value) @method_decorator(clear_cache_if_succeed) def _update(self, request, partial): instance = self.get_object() @@ -778,13 +771,6 @@ def _update(self, request, partial): setattr(instance, key, value) instance.save() - # 审计记录 - create_general_log( - operator=request.operator, - operate_type=OperationEnum.UPDATE.value, - operator_obj=instance, - request=request, - ) return Response(self.serializer_class(instance).data) @swagger_auto_schema(query_serializer=AdvancedRetrieveSerialzier()) diff --git a/src/api/bkuser_core/tests/apis/audits/test_audits.py b/src/api/bkuser_core/tests/apis/audits/test_audits.py index c6fbb2e77..8944996d1 100644 --- a/src/api/bkuser_core/tests/apis/audits/test_audits.py +++ b/src/api/bkuser_core/tests/apis/audits/test_audits.py @@ -9,7 +9,7 @@ specific language governing permissions and limitations under the License. """ import pytest -from bkuser_core.audit.constants import LogInFailReasonEnum, OperationEnum +from bkuser_core.audit.constants import LogInFailReason, OperationType from bkuser_core.audit.utils import create_general_log, create_profile_log from bkuser_core.audit.views import GeneralLogViewSet, LoginLogViewSet, ResetPasswordLogViewSet from bkuser_core.tests.utils import make_simple_profile @@ -30,9 +30,9 @@ def obj_view(self): @pytest.mark.parametrize( "operate_type, username", [ - (OperationEnum.UPDATE.value, "abc"), - (OperationEnum.CREATE.value, "edf"), - (OperationEnum.DELETE.value, "xyz"), + (OperationType.UPDATE.value, "abc"), + (OperationType.CREATE.value, "edf"), + (OperationType.DELETE.value, "xyz"), ], ) def test_normal_list(self, factory, view, operate_type, username): @@ -62,7 +62,7 @@ def test_page_size(self, factory, view, count, page_size, page, expected): create_general_log( operator=operator, operator_obj=operator_obj, - operate_type=OperationEnum.UPDATE.value, + operate_type=OperationType.UPDATE.value, ) request = factory.get(f"/api/v2/general_log/?page={page}&page_size={page_size}") @@ -94,13 +94,13 @@ def test_profile_log(self, factory, view, count, page_size, page, expected): create_profile_log( profile=p, operation="LogIn", - params={"is_success": False, "reason": LogInFailReasonEnum.DISABLED_USER.value}, + params={"is_success": False, "reason": LogInFailReason.DISABLED_USER.value}, ) request = factory.get(f"/api/v2/login_log/?page={page}&page_size={page_size}") response = view(request=request) assert len(response.data["results"]) == expected - assert response.data["results"][0]["reason"] == LogInFailReasonEnum.DISABLED_USER.value + assert response.data["results"][0]["reason"] == LogInFailReason.DISABLED_USER.value class TestResetPasswordApis: diff --git a/src/api/bkuser_core/tests/apis/profiles/test_login.py b/src/api/bkuser_core/tests/apis/profiles/test_login.py index 2efceb77e..3ff336314 100644 --- a/src/api/bkuser_core/tests/apis/profiles/test_login.py +++ b/src/api/bkuser_core/tests/apis/profiles/test_login.py @@ -217,6 +217,13 @@ def test_check_error(self, factory, check_view): p.password_valid_days = 1 p.status = ProfileStatus.NORMAL.value p.save() + request = factory.post( + "/api/v1/login/check/", + data={"username": "logintest", "password": "wrongpwd", "domain": "testdomain"}, + ) + response = check_view(request=request) + assert response.data["code"] == "PASSWORD_ERROR" + request = factory.post( "/api/v1/login/check/", data={"username": "logintest", "password": "testpwd", "domain": "testdomain"}, diff --git a/src/api/bkuser_core/tests/audit/test_utils.py b/src/api/bkuser_core/tests/audit/test_utils.py index 6a9008cce..391fa110a 100644 --- a/src/api/bkuser_core/tests/audit/test_utils.py +++ b/src/api/bkuser_core/tests/audit/test_utils.py @@ -9,8 +9,10 @@ specific language governing permissions and limitations under the License. """ import pytest -from bkuser_core.audit.constants import OperationEnum -from bkuser_core.audit.utils import create_general_log, create_profile_log +from bkuser_core.audit.constants import OperationStatus, OperationType +from bkuser_core.audit.models import GeneralLog +from bkuser_core.audit.utils import audit_general_log, create_general_log, create_profile_log +from bkuser_core.common.error_codes import CoreAPIError, error_codes from bkuser_core.tests import utils pytestmark = pytest.mark.django_db @@ -26,11 +28,11 @@ def make_operator_obj(obj_type_name: str, params: dict): [ ( "zhangsan", - OperationEnum.UPDATE.value, + OperationType.UPDATE.value, "profile", {"username": "aaa", "force_create_params": {"category_id": 1}}, { - "operation": OperationEnum.UPDATE.value, + "operation": OperationType.UPDATE.value, "obj_type": "Profile", "display_name": "AAA", "key": "aaa", @@ -39,14 +41,14 @@ def make_operator_obj(obj_type_name: str, params: dict): ), ( "bbbbb", - OperationEnum.CREATE.value, + OperationType.CREATE.value, "department", { "name": "xxxxx", "force_create_params": {"pk": 10000, "category_id": 1}, }, { - "operation": OperationEnum.CREATE.value, + "operation": OperationType.CREATE.value, "obj_type": "Department", "display_name": "xxxxx", "key": 10000, @@ -55,7 +57,7 @@ def make_operator_obj(obj_type_name: str, params: dict): ), ( "zhangsan", - OperationEnum.UPDATE.value, + OperationType.UPDATE.value, "category", { "domain": "aaa", @@ -63,7 +65,7 @@ def make_operator_obj(obj_type_name: str, params: dict): "force_create_params": {"pk": 10000}, }, { - "operation": OperationEnum.UPDATE.value, + "operation": OperationType.UPDATE.value, "obj_type": "ProfileCategory", "display_name": "qwer", "category_id": 10000, @@ -72,11 +74,11 @@ def make_operator_obj(obj_type_name: str, params: dict): ), ( "zhangsan", - OperationEnum.CREATE.value, + OperationType.CREATE.value, "dynamic_field", {"name": "aaa", "force_create_params": {"display_name": "qqqq"}}, { - "operation": OperationEnum.CREATE.value, + "operation": OperationType.CREATE.value, "obj_type": "DynamicFieldInfo", "display_name": "qqqq", "key": "aaa", @@ -84,11 +86,11 @@ def make_operator_obj(obj_type_name: str, params: dict): ), ( "zhangsan", - OperationEnum.CREATE.value, + OperationType.CREATE.value, "dynamic_field", {"name": "aaa", "force_create_params": {"display_name": "qqqq"}}, { - "operation": OperationEnum.CREATE.value, + "operation": OperationType.CREATE.value, "obj_type": "DynamicFieldInfo", "display_name": "qqqq", "key": "aaa", @@ -108,7 +110,7 @@ def test_create_general_log(self, operator, operate_type, obj_type, params, expe "operation_type, obj", [ ("None", None), - (OperationEnum.CREATE.value, None), + (OperationType.CREATE.value, None), ("ssss", "zhangsan"), ], ) @@ -121,14 +123,8 @@ def test_create_general_log_unknown(self, operation_type, obj): @pytest.mark.parametrize( "operation_type,params", [ - ( - "LogIn", - {}, - ), - ( - "ResetPassword", - {}, - ), + ("LogIn", {}), + ("ResetPassword", {}), ], ) def test_create_profile_log(self, operation_type, params): @@ -140,17 +136,91 @@ def test_create_profile_log(self, operation_type, params): @pytest.mark.parametrize( "operation_type,params", [ - ( - "ccc", - {}, - ), - ( - "uuuu", - {}, - ), + ("ccc", {}), + ("uuuu", {}), ], ) def test_create_profile_log_error(self, operation_type, params): p = utils.make_simple_profile("zhangsan") with pytest.raises(ValueError): create_profile_log(p, operation_type, params=params) + + +class TestAuditGeneralLogDeco: + @pytest.fixture + def fake_request(self): + class FakeRequest: + operator = "fake" + META = {"HTTP_X_FORWARDED_FOR": "0.0.0.0"} + + return FakeRequest() + + @pytest.fixture + def dummy_view(self, test_profile): + class DummyViewSet: + @audit_general_log(OperationType.UPDATE.value) + def succeed_view(self, request, **kwargs): + return "result" + + @audit_general_log(OperationType.RESTORATION.value) + def failed_view(self, request, **kwargs): + raise error_codes.CANNOT_CREATE_SETTING.f("fake value error") + + @audit_general_log(OperationType.DELETE.value) + def unknown_failed_view(self, request, **kwargs): + raise ValueError("fake value error") + + @audit_general_log(OperationType.UPDATE.value) + def update_view(self, request, **kwargs): + test_profile.display_name = "updated" + return "result" + + def get_object(self): + return test_profile + + return DummyViewSet() + + def test_success(self, dummy_view, fake_request): + """test decorator of creating general log""" + assert dummy_view.succeed_view(fake_request) == "result" + + general_log = GeneralLog.objects.order_by("-create_time").first() + assert general_log.status == OperationStatus.SUCCEED.value + assert general_log.extra_value["operation"] == OperationType.UPDATE.value + assert general_log.extra_value["obj_type"] == "Profile" + assert general_log.extra_value["key"] == "fake-test" + + def test_unknown_failure(self, dummy_view, fake_request): + """test decorator of creating general log""" + with pytest.raises(ValueError): + assert dummy_view.unknown_failed_view(fake_request) == "result" + + general_log = GeneralLog.objects.order_by("-create_time").first() + assert general_log.status == OperationStatus.FAILED.value + assert general_log.extra_value["operation"] == OperationType.DELETE.value + assert general_log.extra_value["obj_type"] == "Profile" + assert general_log.extra_value["key"] == "fake-test" + assert general_log.extra_value["failed_info"] == "未知异常,请查阅日志了解详情" + + def test_failure(self, dummy_view, fake_request): + """test decorator of creating general log""" + with pytest.raises(CoreAPIError): + dummy_view.failed_view(fake_request) + + general_log = GeneralLog.objects.order_by("-create_time").first() + assert general_log.status == OperationStatus.FAILED.value + assert general_log.extra_value["operation"] == OperationType.RESTORATION.value + assert general_log.extra_value["obj_type"] == "Profile" + assert general_log.extra_value["key"] == "fake-test" + assert general_log.extra_value["failed_info"] == "无法创建配置, fake value error" + + def test_updated(self, dummy_view, fake_request): + """test decorator of creating general log""" + assert dummy_view.update_view(fake_request) == "result" + + general_log = GeneralLog.objects.order_by("-create_time").first() + assert general_log.status == OperationStatus.SUCCEED.value + assert general_log.extra_value["operation"] == OperationType.UPDATE.value + assert general_log.extra_value["obj_type"] == "Profile" + assert general_log.extra_value["key"] == "fake-test" + assert general_log.extra_value["display_name"] == "updated" diff --git a/src/api/bkuser_core/tests/common/test_middlewares.py b/src/api/bkuser_core/tests/common/test_middlewares.py new file mode 100644 index 000000000..99f13f1bc --- /dev/null +++ b/src/api/bkuser_core/tests/common/test_middlewares.py @@ -0,0 +1,39 @@ +# -*- 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 json + +import pytest +from bkuser_core.common.middlewares import MethodOverrideMiddleware +from bkuser_core.tests.apis.utils import get_api_factory + + +class TestMethodOverrideMW: + @pytest.fixture() + def factory(self): + return get_api_factory({"HTTP_X_HTTP_METHOD_OVERRIDE": "GET"}) + + @pytest.mark.parametrize( + "post_data", + [ + {"username": "test_name", "password": "test_pwd"}, + {"age": 33, "gender": "female"}, + {"exact_lookups": ["aa", "bb", "cc"]}, + ], + ) + def test_process_request(self, factory, post_data): + request = factory.post("/", data=post_data) + request._body = json.dumps(post_data) + + method_override_middleware = MethodOverrideMiddleware() + method_override_middleware.process_request(request) + + assert request.method == request.META["HTTP_X_HTTP_METHOD_OVERRIDE"] + assert request.GET.dict() == post_data diff --git a/src/api/bkuser_core/urls.py b/src/api/bkuser_core/urls.py index 38cb0dbf9..3b289e2ec 100644 --- a/src/api/bkuser_core/urls.py +++ b/src/api/bkuser_core/urls.py @@ -36,6 +36,7 @@ logger.exception("failed to load urls from installed app: %s", app) continue +urlpatterns += [url(r"^", include("django_prometheus.urls"))] if "silk" in settings.INSTALLED_APPS: urlpatterns += [url(r"^silk/", include("silk.urls", namespace="silk"))] diff --git a/src/api/bkuser_core/user_settings/migrations/0009_alter_settingmeta_category_type.py b/src/api/bkuser_core/user_settings/migrations/0009_alter_settingmeta_category_type.py new file mode 100644 index 000000000..4c6ac42bd --- /dev/null +++ b/src/api/bkuser_core/user_settings/migrations/0009_alter_settingmeta_category_type.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.5 on 2021-09-26 11:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('user_settings', '0008_auto_20210706_1702'), + ] + + operations = [ + migrations.AlterField( + model_name='settingmeta', + name='category_type', + field=models.CharField(choices=[('local', '本地目录'), ('mad', 'Microsoft Active Directory'), ('ldap', 'OpenLDAP'), ('tof', 'TOF'), ('custom', '自定义目录'), ('pluggable', '可插拔目录')], max_length=32, verbose_name='类型'), + ), + ] diff --git a/src/api/bkuser_core/user_settings/signals.py b/src/api/bkuser_core/user_settings/signals.py index ac2ae6cdd..c94a8f802 100644 --- a/src/api/bkuser_core/user_settings/signals.py +++ b/src/api/bkuser_core/user_settings/signals.py @@ -10,4 +10,5 @@ """ import django -post_setting_create_or_update = django.dispatch.Signal(providing_args=["setting", "operator"]) +post_setting_create = django.dispatch.Signal(providing_args=["instance", "operator", "extra_values"]) +post_setting_update = django.dispatch.Signal(providing_args=["instance", "operator", "extra_values"]) diff --git a/src/api/bkuser_core/user_settings/views.py b/src/api/bkuser_core/user_settings/views.py index 48f7244b3..f3cd4b111 100644 --- a/src/api/bkuser_core/user_settings/views.py +++ b/src/api/bkuser_core/user_settings/views.py @@ -14,16 +14,15 @@ from bkuser_core.common.cache import clear_cache_if_succeed from bkuser_core.common.error_codes import error_codes from bkuser_core.common.viewset import AdvancedListAPIView, AdvancedModelViewSet +from bkuser_core.user_settings import serializers +from bkuser_core.user_settings.models import Setting, SettingMeta +from bkuser_core.user_settings.serializers import SettingUpdateSerializer +from bkuser_core.user_settings.signals import post_setting_create, post_setting_update from django.utils.decorators import method_decorator from drf_yasg.utils import swagger_auto_schema from rest_framework import status from rest_framework.response import Response -from . import serializers -from .models import Setting, SettingMeta -from .serializers import SettingUpdateSerializer -from .signals import post_setting_create_or_update - logger = logging.getLogger(__name__) @@ -92,7 +91,9 @@ def create(self, request, *args, **kwargs): logger.exception("cannot create setting") raise error_codes.CANNOT_CREATE_SETTING - post_setting_create_or_update.send(sender=setting, setting=setting, operator=request.operator) + post_setting_create.send( + sender=self, instance=setting, operator=request.operator, extra_values={"request": request} + ) return Response(serializers.SettingSerializer(setting).data, status=status.HTTP_201_CREATED) @swagger_auto_schema( @@ -101,7 +102,9 @@ def create(self, request, *args, **kwargs): ) def update(self, request, *args, **kwargs): result = super().update(request, *args, **kwargs) - post_setting_create_or_update.send(sender=self, setting=self.get_object(), operator=request.operator) + post_setting_update.send( + sender=self, instance=self.get_object(), operator=request.operator, extra_values={"request": request} + ) return result @swagger_auto_schema( @@ -110,7 +113,9 @@ def update(self, request, *args, **kwargs): ) def partial_update(self, request, *args, **kwargs): result = super().partial_update(request, *args, **kwargs) - post_setting_create_or_update.send(sender=self, setting=self.get_object(), operator=request.operator) + post_setting_update.send( + sender=self, instance=self.get_object(), operator=request.operator, extra_values={"request": request} + ) return result diff --git a/src/api/poetry.lock b/src/api/poetry.lock index 3c3766323..e587beb54 100644 --- a/src/api/poetry.lock +++ b/src/api/poetry.lock @@ -538,6 +538,22 @@ type = "legacy" url = "https://mirrors.tencent.com/pypi/simple" reference = "tencent-mirrors" +[[package]] +name = "django-prometheus" +version = "2.1.0" +description = "Django middlewares to monitor your application with Prometheus.io." +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +prometheus-client = ">=0.7" + +[package.source] +type = "legacy" +url = "https://mirrors.tencent.com/pypi/simple" +reference = "tencent-mirrors" + [[package]] name = "django-redis" version = "4.12.1" @@ -1160,6 +1176,22 @@ type = "legacy" url = "https://mirrors.tencent.com/pypi/simple" reference = "tencent-mirrors" +[[package]] +name = "prometheus-client" +version = "0.12.0" +description = "Python client for the Prometheus monitoring system." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.extras] +twisted = ["twisted"] + +[package.source] +type = "legacy" +url = "https://mirrors.tencent.com/pypi/simple" +reference = "tencent-mirrors" + [[package]] name = "prompt-toolkit" version = "3.0.19" @@ -1833,7 +1865,7 @@ reference = "tencent-mirrors" [metadata] lock-version = "1.1" python-versions = "3.6.14" -content-hash = "1320f793d7de7d1b20a05679d662fb5195dfffc91045b6dea9191d149523bd1a" +content-hash = "9e24b2f0dff4561390ae71ec18233f6c1cbffad065b257a609ed909b5ee139b8" [metadata.files] aenum = [ @@ -2057,6 +2089,10 @@ django-mptt = [ {file = "django-mptt-0.12.0.tar.gz", hash = "sha256:8ae6c3821127b529bb2f938de27bf0771b1bcbe9dbccdfba33986af78611f13a"}, {file = "django_mptt-0.12.0-py2.py3-none-any.whl", hash = "sha256:63b421a054bceb7406582e2be876a80b3848a5106765baea1003696348ffd628"}, ] +django-prometheus = [ + {file = "django-prometheus-2.1.0.tar.gz", hash = "sha256:dd3f8da1399140fbef5c00d1526a23d1ade286b144281c325f8e409a781643f2"}, + {file = "django_prometheus-2.1.0-py2.py3-none-any.whl", hash = "sha256:c338d6efde1ca336e90c540b5e87afe9287d7bcc82d651a778f302b0be17a933"}, +] django-redis = [ {file = "django-redis-4.12.1.tar.gz", hash = "sha256:306589c7021e6468b2656edc89f62b8ba67e8d5a1c8877e2688042263daa7a63"}, {file = "django_redis-4.12.1-py3-none-any.whl", hash = "sha256:1133b26b75baa3664164c3f44b9d5d133d1b8de45d94d79f38d1adc5b1d502e5"}, @@ -2318,6 +2354,10 @@ pluggy = [ {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, ] +prometheus-client = [ + {file = "prometheus_client-0.12.0-py2.py3-none-any.whl", hash = "sha256:317453ebabff0a1b02df7f708efbab21e3489e7072b61cb6957230dd004a0af0"}, + {file = "prometheus_client-0.12.0.tar.gz", hash = "sha256:1b12ba48cee33b9b0b9de64a1047cbd3c5f2d0ab6ebcead7ddda613a750ec3c5"}, +] prompt-toolkit = [ {file = "prompt_toolkit-3.0.19-py3-none-any.whl", hash = "sha256:7089d8d2938043508aa9420ec18ce0922885304cddae87fb96eebca942299f88"}, {file = "prompt_toolkit-3.0.19.tar.gz", hash = "sha256:08360ee3a3148bdb5163621709ee322ec34fc4375099afa4bbf751e9b7b7fa4f"}, diff --git a/src/api/pyproject.toml b/src/api/pyproject.toml index 74131f3a1..6ac00ccff 100644 --- a/src/api/pyproject.toml +++ b/src/api/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "bk-user-api" -version = "2.3.0" +version = "2.3.1" description = "bk-user Api" authors = ["IMBlues "] @@ -36,6 +36,7 @@ gevent = "1.1.2" cryptography = "^3.0.0" pydantic = "^1.8.2" django-environ = "^0.4.5" +django-prometheus = "^2.1.0" [tool.poetry.dev-dependencies] ipython = "^7.15.0" diff --git a/src/pages/src/store/modules/organization.js b/src/pages/src/store/modules/organization.js index c9e258ac0..1e77be6d3 100644 --- a/src/pages/src/store/modules/organization.js +++ b/src/pages/src/store/modules/organization.js @@ -113,5 +113,10 @@ export default { getProfileById(context, params, config = {}) { return http.get(`api/v2/profiles/${params.id}/`); }, + // 恢复删除用户 + postProfilesRestoration(context, params, config = {}) { + const { id } = params; + return http.post(`/api/v2/profiles/${id}/restoration/`); + }, }, }; diff --git a/src/pages/src/views/organization/details/DetailsBar.vue b/src/pages/src/views/organization/details/DetailsBar.vue index 852aa12db..573ac2e70 100644 --- a/src/pages/src/views/organization/details/DetailsBar.vue +++ b/src/pages/src/views/organization/details/DetailsBar.vue @@ -30,6 +30,7 @@ :fields-list="fieldsList" @editProfile="editProfile" @deleteProfile="$emit('deleteProfile')" + @restoreProfile="$emit('restoreProfile')" @getTableData="$emit('getTableData')" @showBarLoading="$emit('showBarLoading')" @closeBarLoading="$emit('closeBarLoading')" /> diff --git a/src/pages/src/views/organization/details/UserMaterial.vue b/src/pages/src/views/organization/details/UserMaterial.vue index 54d3e49fc..5df64ae86 100644 --- a/src/pages/src/views/organization/details/UserMaterial.vue +++ b/src/pages/src/views/organization/details/UserMaterial.vue @@ -22,24 +22,31 @@