diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index 5c40fded3..879177926 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -4,6 +4,9 @@ ##### 使用版本 [请提供你使用的JumpServer版本 如 2.0.1 注: 1.4及以下版本不再提供支持] +##### 使用浏览器版本 +[请提供你使用的浏览器版本 如 Chrome 84.0.4147.105 ] + ##### 问题复现步骤 1. [步骤1] 2. [步骤2] diff --git a/Dockerfile b/Dockerfile index 869955f17..7cf768d85 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,18 +9,23 @@ RUN cd utils && bash -ixeu build.sh FROM registry.fit2cloud.com/public/python:v3 +ARG PIP_MIRROR=https://pypi.douban.com/simple +ENV PIP_MIRROR=$PIP_MIRROR +ARG MYSQL_MIRROR=https://mirrors.tuna.tsinghua.edu.cn/mysql/yum/mysql57-community-el6/ +ENV MYSQL_MIRROR=$MYSQL_MIRROR + WORKDIR /opt/jumpserver -COPY --from=stage-build /opt/jumpserver/release/jumpserver /opt/jumpserver +COPY ./requirements ./requirements RUN useradd jumpserver - RUN yum -y install epel-release && \ - echo -e "[mysql]\nname=mysql\nbaseurl=https://mirrors.tuna.tsinghua.edu.cn/mysql/yum/mysql57-community-el6/\ngpgcheck=0\nenabled=1" > /etc/yum.repos.d/mysql.repo - -COPY . . + echo -e "[mysql]\nname=mysql\nbaseurl=${MYSQL_MIRROR}\ngpgcheck=0\nenabled=1" > /etc/yum.repos.d/mysql.repo RUN yum -y install $(cat requirements/rpm_requirements.txt) -RUN pip install --upgrade pip setuptools && pip install wheel && \ - pip install -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements/requirements.txt || pip install -r requirements/requirements.txt +RUN pip install --upgrade pip setuptools wheel -i ${PIP_MIRROR} && \ + pip config set global.index-url ${PIP_MIRROR} +RUN pip install -r requirements/requirements.txt || pip install -r requirements/requirements.txt + +COPY --from=stage-build /opt/jumpserver/release/jumpserver /opt/jumpserver RUN mkdir -p /root/.ssh/ && echo -e "Host *\n\tStrictHostKeyChecking no\n\tUserKnownHostsFile /dev/null" > /root/.ssh/config RUN echo > config.yml diff --git a/apps/applications/api/__init__.py b/apps/applications/api/__init__.py index a707cfde6..0e6e940ee 100644 --- a/apps/applications/api/__init__.py +++ b/apps/applications/api/__init__.py @@ -1,2 +1,3 @@ from .remote_app import * from .database_app import * +from .k8s_app import * diff --git a/apps/applications/api/k8s_app.py b/apps/applications/api/k8s_app.py new file mode 100644 index 000000000..5cc63b546 --- /dev/null +++ b/apps/applications/api/k8s_app.py @@ -0,0 +1,20 @@ +# coding: utf-8 +# + +from orgs.mixins.api import OrgBulkModelViewSet + +from .. import models +from .. import serializers +from ..hands import IsOrgAdminOrAppUser + +__all__ = [ + 'K8sAppViewSet', +] + + +class K8sAppViewSet(OrgBulkModelViewSet): + model = models.K8sApp + filter_fields = ('name',) + search_fields = filter_fields + permission_classes = (IsOrgAdminOrAppUser,) + serializer_class = serializers.K8sAppSerializer diff --git a/apps/applications/const.py b/apps/applications/const.py index af3531c36..773f30b9f 100644 --- a/apps/applications/const.py +++ b/apps/applications/const.py @@ -23,6 +23,7 @@ REMOTE_APP_TYPE_CHROME_FIELDS = [ REMOTE_APP_TYPE_MYSQL_WORKBENCH_FIELDS = [ {'name': 'mysql_workbench_ip'}, {'name': 'mysql_workbench_name'}, + {'name': 'mysql_workbench_port'}, {'name': 'mysql_workbench_username'}, {'name': 'mysql_workbench_password', 'write_only': True} ] diff --git a/apps/applications/migrations/0005_k8sapp.py b/apps/applications/migrations/0005_k8sapp.py new file mode 100644 index 000000000..3f6964a88 --- /dev/null +++ b/apps/applications/migrations/0005_k8sapp.py @@ -0,0 +1,34 @@ +# Generated by Django 2.2.13 on 2020-08-07 07:13 + +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('applications', '0004_auto_20191218_1705'), + ] + + operations = [ + migrations.CreateModel( + name='K8sApp', + fields=[ + ('created_by', models.CharField(blank=True, max_length=32, null=True, verbose_name='Created by')), + ('updated_by', models.CharField(blank=True, max_length=32, null=True, verbose_name='Updated by')), + ('date_created', models.DateTimeField(auto_now_add=True, null=True, verbose_name='Date created')), + ('date_updated', models.DateTimeField(auto_now=True, verbose_name='Date updated')), + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('org_id', models.CharField(blank=True, db_index=True, default='', max_length=36, verbose_name='Organization')), + ('name', models.CharField(max_length=128, verbose_name='Name')), + ('type', models.CharField(choices=[('k8s', 'Kubernetes')], default='k8s', max_length=128, verbose_name='Type')), + ('cluster', models.CharField(max_length=1024, verbose_name='Cluster')), + ('comment', models.TextField(blank=True, default='', max_length=128, verbose_name='Comment')), + ], + options={ + 'verbose_name': 'KubernetesApp', + 'ordering': ('name',), + 'unique_together': {('org_id', 'name')}, + }, + ), + ] diff --git a/apps/applications/models/__init__.py b/apps/applications/models/__init__.py index a707cfde6..0e6e940ee 100644 --- a/apps/applications/models/__init__.py +++ b/apps/applications/models/__init__.py @@ -1,2 +1,3 @@ from .remote_app import * from .database_app import * +from .k8s_app import * diff --git a/apps/applications/models/k8s_app.py b/apps/applications/models/k8s_app.py new file mode 100644 index 000000000..c4f0591ca --- /dev/null +++ b/apps/applications/models/k8s_app.py @@ -0,0 +1,27 @@ +from django.utils.translation import gettext_lazy as _ + +from common.db import models +from orgs.mixins.models import OrgModelMixin + + +class K8sApp(OrgModelMixin, models.JMSModel): + class TYPE(models.ChoiceSet): + K8S = 'k8s', _('Kubernetes') + + name = models.CharField(max_length=128, verbose_name=_('Name')) + type = models.CharField( + default=TYPE.K8S, choices=TYPE.choices, + max_length=128, verbose_name=_('Type') + ) + cluster = models.CharField(max_length=1024, verbose_name=_('Cluster')) + comment = models.TextField( + max_length=128, default='', blank=True, verbose_name=_('Comment') + ) + + def __str__(self): + return self.name + + class Meta: + unique_together = [('org_id', 'name'), ] + verbose_name = _('KubernetesApp') + ordering = ('name', ) diff --git a/apps/applications/serializers/__init__.py b/apps/applications/serializers/__init__.py index a707cfde6..0e6e940ee 100644 --- a/apps/applications/serializers/__init__.py +++ b/apps/applications/serializers/__init__.py @@ -1,2 +1,3 @@ from .remote_app import * from .database_app import * +from .k8s_app import * diff --git a/apps/applications/serializers/k8s_app.py b/apps/applications/serializers/k8s_app.py new file mode 100644 index 000000000..68fafbc86 --- /dev/null +++ b/apps/applications/serializers/k8s_app.py @@ -0,0 +1,22 @@ +from rest_framework import serializers + +from orgs.mixins.serializers import BulkOrgResourceModelSerializer +from .. import models + +__all__ = [ + 'K8sAppSerializer', +] + + +class K8sAppSerializer(BulkOrgResourceModelSerializer): + type_display = serializers.CharField(source='get_type_display', read_only=True) + + class Meta: + model = models.K8sApp + fields = [ + 'id', 'name', 'type', 'type_display', 'comment', 'created_by', + 'date_created', 'date_updated', 'cluster' + ] + read_only_fields = [ + 'id', 'created_by', 'date_created', 'date_updated', + ] diff --git a/apps/applications/urls/api_urls.py b/apps/applications/urls/api_urls.py index 1186bf1a2..42d0fe524 100644 --- a/apps/applications/urls/api_urls.py +++ b/apps/applications/urls/api_urls.py @@ -12,6 +12,7 @@ app_name = 'applications' router = BulkRouter() router.register(r'remote-apps', api.RemoteAppViewSet, 'remote-app') router.register(r'database-apps', api.DatabaseAppViewSet, 'database-app') +router.register(r'k8s-apps', api.K8sAppViewSet, 'k8s-app') urlpatterns = [ path('remote-apps//connection-info/', api.RemoteAppConnectionInfoApi.as_view(), name='remote-app-connection-info'), diff --git a/apps/assets/migrations/0053_auto_20200723_1232.py b/apps/assets/migrations/0053_auto_20200723_1232.py new file mode 100644 index 000000000..7d1ba220d --- /dev/null +++ b/apps/assets/migrations/0053_auto_20200723_1232.py @@ -0,0 +1,34 @@ +# Generated by Django 2.2.10 on 2020-07-23 04:32 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('assets', '0052_auto_20200715_1535'), + ] + + operations = [ + migrations.AlterField( + model_name='adminuser', + name='username', + field=models.CharField(blank=True, db_index=True, max_length=128, validators=[django.core.validators.RegexValidator('^[0-9a-zA-Z_@\\-\\.]*$', 'Special char not allowed')], verbose_name='Username'), + ), + migrations.AlterField( + model_name='authbook', + name='username', + field=models.CharField(blank=True, db_index=True, max_length=128, validators=[django.core.validators.RegexValidator('^[0-9a-zA-Z_@\\-\\.]*$', 'Special char not allowed')], verbose_name='Username'), + ), + migrations.AlterField( + model_name='gateway', + name='username', + field=models.CharField(blank=True, db_index=True, max_length=128, validators=[django.core.validators.RegexValidator('^[0-9a-zA-Z_@\\-\\.]*$', 'Special char not allowed')], verbose_name='Username'), + ), + migrations.AlterField( + model_name='systemuser', + name='username', + field=models.CharField(blank=True, db_index=True, max_length=128, validators=[django.core.validators.RegexValidator('^[0-9a-zA-Z_@\\-\\.]*$', 'Special char not allowed')], verbose_name='Username'), + ), + ] diff --git a/apps/assets/migrations/0054_auto_20200807_1032.py b/apps/assets/migrations/0054_auto_20200807_1032.py new file mode 100644 index 000000000..288b78e25 --- /dev/null +++ b/apps/assets/migrations/0054_auto_20200807_1032.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.13 on 2020-08-07 02:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('assets', '0053_auto_20200723_1232'), + ] + + operations = [ + migrations.AddField( + model_name='systemuser', + name='token', + field=models.TextField(default='', verbose_name='Token'), + ), + migrations.AlterField( + model_name='systemuser', + name='protocol', + field=models.CharField(choices=[('ssh', 'ssh'), ('rdp', 'rdp'), ('telnet', 'telnet'), ('vnc', 'vnc'), ('mysql', 'mysql'), ('k8s', 'k8s')], default='ssh', max_length=16, verbose_name='Protocol'), + ), + ] diff --git a/apps/assets/migrations/0055_auto_20200811_1845.py b/apps/assets/migrations/0055_auto_20200811_1845.py new file mode 100644 index 000000000..739378c78 --- /dev/null +++ b/apps/assets/migrations/0055_auto_20200811_1845.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.13 on 2020-08-11 10:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('assets', '0054_auto_20200807_1032'), + ] + + operations = [ + migrations.AddField( + model_name='systemuser', + name='home', + field=models.CharField(blank=True, default='', max_length=4096, verbose_name='Home'), + ), + migrations.AddField( + model_name='systemuser', + name='system_groups', + field=models.CharField(blank=True, default='', max_length=4096, verbose_name='System groups'), + ), + ] diff --git a/apps/assets/models/base.py b/apps/assets/models/base.py index 3c9a481a5..282c9b928 100644 --- a/apps/assets/models/base.py +++ b/apps/assets/models/base.py @@ -230,7 +230,7 @@ class AuthMixin: class BaseUser(OrgModelMixin, AuthMixin, ConnectivityMixin): id = models.UUIDField(default=uuid.uuid4, primary_key=True) name = models.CharField(max_length=128, verbose_name=_('Name')) - username = models.CharField(max_length=32, blank=True, verbose_name=_('Username'), validators=[alphanumeric], db_index=True) + username = models.CharField(max_length=128, blank=True, verbose_name=_('Username'), validators=[alphanumeric], db_index=True) password = fields.EncryptCharField(max_length=256, blank=True, null=True, verbose_name=_('Password')) private_key = fields.EncryptTextField(blank=True, null=True, verbose_name=_('SSH private key')) public_key = fields.EncryptTextField(blank=True, null=True, verbose_name=_('SSH public key')) diff --git a/apps/assets/models/user.py b/apps/assets/models/user.py index 7085a3b2b..3fb862760 100644 --- a/apps/assets/models/user.py +++ b/apps/assets/models/user.py @@ -9,6 +9,7 @@ from django.utils.translation import ugettext_lazy as _ from django.core.validators import MinValueValidator, MaxValueValidator from common.utils import signer +from common.fields.model import JsonListCharField from .base import BaseUser from .asset import Asset @@ -91,12 +92,14 @@ class SystemUser(BaseUser): PROTOCOL_TELNET = 'telnet' PROTOCOL_VNC = 'vnc' PROTOCOL_MYSQL = 'mysql' + PROTOCOL_K8S = 'k8s' PROTOCOL_CHOICES = ( (PROTOCOL_SSH, 'ssh'), (PROTOCOL_RDP, 'rdp'), (PROTOCOL_TELNET, 'telnet'), (PROTOCOL_VNC, 'vnc'), (PROTOCOL_MYSQL, 'mysql'), + (PROTOCOL_K8S, 'k8s'), ) LOGIN_AUTO = 'auto' @@ -118,6 +121,9 @@ class SystemUser(BaseUser): login_mode = models.CharField(choices=LOGIN_MODE_CHOICES, default=LOGIN_AUTO, max_length=10, verbose_name=_('Login mode')) cmd_filters = models.ManyToManyField('CommandFilter', related_name='system_users', verbose_name=_("Command filter"), blank=True) sftp_root = models.CharField(default='tmp', max_length=128, verbose_name=_("SFTP Root")) + token = models.TextField(default='', verbose_name=_('Token')) + home = models.CharField(max_length=4096, default='', verbose_name=_('Home'), blank=True) + system_groups = models.CharField(default='', max_length=4096, verbose_name=_('System groups'), blank=True) _prefer = 'system_user' def __str__(self): diff --git a/apps/assets/serializers/system_user.py b/apps/assets/serializers/system_user.py index 7f3ad5372..0fcff5b1d 100644 --- a/apps/assets/serializers/system_user.py +++ b/apps/assets/serializers/system_user.py @@ -33,13 +33,15 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer): 'login_mode', 'login_mode_display', 'priority', 'username_same_with_user', 'auto_push', 'cmd_filters', 'sudo', 'shell', 'comment', - 'auto_generate_key', 'sftp_root', - 'assets_amount', 'date_created', 'created_by' + 'auto_generate_key', 'sftp_root', 'token', + 'assets_amount', 'date_created', 'created_by', + 'home', 'system_groups' ] extra_kwargs = { 'password': {"write_only": True}, 'public_key': {"write_only": True}, 'private_key': {"write_only": True}, + 'token': {"write_only": True}, 'nodes_amount': {'label': _('Node')}, 'assets_amount': {'label': _('Asset')}, 'login_mode_display': {'label': _('Login mode display')}, @@ -143,13 +145,14 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer): class SystemUserListSerializer(SystemUserSerializer): + class Meta(SystemUserSerializer.Meta): fields = [ 'id', 'name', 'username', 'protocol', 'login_mode', 'login_mode_display', 'priority', "username_same_with_user", 'auto_push', 'sudo', 'shell', 'comment', - "assets_amount", + "assets_amount", 'home', 'system_groups', 'auto_generate_key', 'sftp_root', ] @@ -169,7 +172,7 @@ class SystemUserWithAuthInfoSerializer(SystemUserSerializer): 'login_mode', 'login_mode_display', 'priority', 'username_same_with_user', 'auto_push', 'sudo', 'shell', 'comment', - 'auto_generate_key', 'sftp_root', + 'auto_generate_key', 'sftp_root', 'token' ] extra_kwargs = { 'nodes_amount': {'label': _('Node')}, diff --git a/apps/assets/tasks/push_system_user.py b/apps/assets/tasks/push_system_user.py index eb3978178..d947be77d 100644 --- a/apps/assets/tasks/push_system_user.py +++ b/apps/assets/tasks/push_system_user.py @@ -3,9 +3,10 @@ from itertools import groupby from celery import shared_task from django.utils.translation import ugettext as _ +from django.db.models import Empty from common.utils import encrypt_password, get_logger -from orgs.utils import tmp_to_org, org_aware_func +from orgs.utils import org_aware_func from . import const from .utils import clean_ansible_task_hosts, group_asset_by_platform @@ -17,20 +18,42 @@ __all__ = [ ] +def _split_by_comma(raw: str): + try: + return [i.strip() for i in raw.split(',')] + except AttributeError: + return [] + + +def _dump_args(args: dict): + return ' '.join([f'{k}={v}' for k, v in args.items() if v is not Empty]) + + def get_push_unixlike_system_user_tasks(system_user, username=None): if username is None: username = system_user.username password = system_user.password public_key = system_user.public_key + groups = _split_by_comma(system_user.system_groups) + + if groups: + groups = '"%s"' % ','.join(groups) + + add_user_args = { + 'name': username, + 'shell': system_user.shell or Empty, + 'state': 'present', + 'home': system_user.home or Empty, + 'groups': groups or Empty + } + tasks = [ { 'name': 'Add user {}'.format(username), 'action': { 'module': 'user', - 'args': 'name={} shell={} state=present'.format( - username, system_user.shell or '/bin/bash', - ), + 'args': _dump_args(add_user_args), } }, { @@ -102,6 +125,11 @@ def get_push_windows_system_user_tasks(system_user, username=None): if username is None: username = system_user.username password = system_user.password + groups = {'Users', 'Remote Desktop Users'} + if system_user.system_groups: + groups.update(_split_by_comma(system_user.system_groups)) + groups = ','.join(groups) + tasks = [] if not password: return tasks @@ -116,9 +144,9 @@ def get_push_windows_system_user_tasks(system_user, username=None): 'update_password=always ' 'password_expired=no ' 'password_never_expires=yes ' - 'groups="Users,Remote Desktop Users" ' + 'groups="{}" ' 'groups_action=add ' - ''.format(username, username, password), + ''.format(username, username, password, groups), } } tasks.append(task) diff --git a/apps/assets/urls/api_urls.py b/apps/assets/urls/api_urls.py index 279bd26be..d70accc23 100644 --- a/apps/assets/urls/api_urls.py +++ b/apps/assets/urls/api_urls.py @@ -1,7 +1,6 @@ # coding:utf-8 from django.urls import path, re_path from rest_framework_nested import routers -# from rest_framework.routers import DefaultRouter from rest_framework_bulk.routes import BulkRouter from common import api as capi diff --git a/apps/audits/api.py b/apps/audits/api.py index 7397b3596..0233bc1b2 100644 --- a/apps/audits/api.py +++ b/apps/audits/api.py @@ -43,7 +43,7 @@ class UserLoginLogViewSet(ListModelMixin, CommonGenericViewSet): @staticmethod def get_org_members(): - users = current_org.get_org_members().values_list('username', flat=True) + users = current_org.get_members().values_list('username', flat=True) return users def get_queryset(self): @@ -79,7 +79,7 @@ class PasswordChangeLogViewSet(ListModelMixin, CommonGenericViewSet): ordering = ['-datetime'] def get_queryset(self): - users = current_org.get_org_members() + users = current_org.get_members() queryset = super().get_queryset().filter( user__in=[user.__str__() for user in users] ) diff --git a/apps/audits/filters.py b/apps/audits/filters.py index 6db2d9b21..470c2c4b5 100644 --- a/apps/audits/filters.py +++ b/apps/audits/filters.py @@ -20,7 +20,7 @@ class CurrentOrgMembersFilter(filters.BaseFilterBackend): ] def _get_user_list(self): - users = current_org.get_org_members(exclude=('Auditor',)) + users = current_org.get_members(exclude=('Auditor',)) return users def filter_queryset(self, request, queryset, view): diff --git a/apps/audits/migrations/0010_auto_20200811_1122.py b/apps/audits/migrations/0010_auto_20200811_1122.py new file mode 100644 index 000000000..f274bf815 --- /dev/null +++ b/apps/audits/migrations/0010_auto_20200811_1122.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.13 on 2020-08-11 03:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('audits', '0009_auto_20200624_1654'), + ] + + operations = [ + migrations.AlterField( + model_name='operatelog', + name='datetime', + field=models.DateTimeField(auto_now=True, db_index=True, verbose_name='Datetime'), + ), + ] diff --git a/apps/audits/models.py b/apps/audits/models.py index 38a41554f..c959bc35c 100644 --- a/apps/audits/models.py +++ b/apps/audits/models.py @@ -58,7 +58,7 @@ class OperateLog(OrgModelMixin): resource_type = models.CharField(max_length=64, verbose_name=_("Resource Type")) resource = models.CharField(max_length=128, verbose_name=_("Resource")) remote_addr = models.CharField(max_length=128, verbose_name=_("Remote addr"), blank=True, null=True) - datetime = models.DateTimeField(auto_now=True, verbose_name=_('Datetime')) + datetime = models.DateTimeField(auto_now=True, verbose_name=_('Datetime'), db_index=True) def __str__(self): return "<{}> {} <{}>".format(self.user, self.action, self.resource) @@ -124,7 +124,7 @@ class UserLoginLog(models.Model): Q(username__contains=keyword) ) if not current_org.is_root(): - username_list = current_org.get_org_members().values_list('username', flat=True) + username_list = current_org.get_members().values_list('username', flat=True) login_logs = login_logs.filter(username__in=username_list) return login_logs diff --git a/apps/authentication/api/__init__.py b/apps/authentication/api/__init__.py index 4f6475124..af5d8d1b4 100644 --- a/apps/authentication/api/__init__.py +++ b/apps/authentication/api/__init__.py @@ -6,3 +6,4 @@ from .token import * from .mfa import * from .access_key import * from .login_confirm import * +from .sso import * diff --git a/apps/authentication/api/sso.py b/apps/authentication/api/sso.py new file mode 100644 index 000000000..b953b8d37 --- /dev/null +++ b/apps/authentication/api/sso.py @@ -0,0 +1,86 @@ +from uuid import UUID +from urllib.parse import urlencode + +from django.contrib.auth import login +from django.conf import settings +from django.http.response import HttpResponseRedirect +from rest_framework.decorators import action +from rest_framework.response import Response +from rest_framework.request import Request + +from common.utils.timezone import utcnow +from common.const.http import POST, GET +from common.drf.api import JmsGenericViewSet +from common.drf.serializers import EmptySerializer +from common.permissions import IsSuperUser +from common.utils import reverse +from users.models import User +from ..serializers import SSOTokenSerializer +from ..models import SSOToken +from ..filters import AuthKeyQueryDeclaration +from ..mixins import AuthMixin +from ..errors import SSOAuthClosed + +NEXT_URL = 'next' +AUTH_KEY = 'authkey' + + +class SSOViewSet(AuthMixin, JmsGenericViewSet): + queryset = SSOToken.objects.all() + serializer_classes = { + 'login_url': SSOTokenSerializer, + 'login': EmptySerializer + } + + @action(methods=[POST], detail=False, permission_classes=[IsSuperUser], url_path='login-url') + def login_url(self, request, *args, **kwargs): + if not settings.AUTH_SSO: + raise SSOAuthClosed() + + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + username = serializer.validated_data['username'] + user = User.objects.get(username=username) + next_url = serializer.validated_data.get(NEXT_URL) + + operator = request.user.username + # TODO `created_by` 和 `created_by` 可以通过 `ThreadLocal` 统一处理 + token = SSOToken.objects.create(user=user, created_by=operator, updated_by=operator) + query = { + AUTH_KEY: token.authkey, + NEXT_URL: next_url or '' + } + login_url = '%s?%s' % (reverse('api-auth:sso-login', external=True), urlencode(query)) + return Response(data={'login_url': login_url}) + + @action(methods=[GET], detail=False, filter_backends=[AuthKeyQueryDeclaration], permission_classes=[]) + def login(self, request: Request, *args, **kwargs): + """ + 此接口违反了 `Restful` 的规范 + `GET` 应该是安全的方法,但此接口是不安全的 + """ + authkey = request.query_params.get(AUTH_KEY) + next_url = request.query_params.get(NEXT_URL) + if not next_url or not next_url.startswith('/'): + next_url = reverse('index') + + try: + authkey = UUID(authkey) + token = SSOToken.objects.get(authkey=authkey, expired=False) + # 先过期,只能访问这一次 + token.expired = True + token.save() + except (ValueError, SSOToken.DoesNotExist): + self.send_auth_signal(success=False, reason='authkey_invalid') + return HttpResponseRedirect(reverse('authentication:login')) + + # 判断是否过期 + if (utcnow().timestamp() - token.date_created.timestamp()) > settings.AUTH_SSO_AUTHKEY_TTL: + self.send_auth_signal(success=False, reason='authkey_timeout') + return HttpResponseRedirect(reverse('authentication:login')) + + user = token.user + login(self.request, user, 'authentication.backends.api.SSOAuthentication') + self.send_auth_signal(success=True, user=user) + return HttpResponseRedirect(next_url) diff --git a/apps/authentication/api/token.py b/apps/authentication/api/token.py index 6c6a34aa2..f7516496c 100644 --- a/apps/authentication/api/token.py +++ b/apps/authentication/api/token.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # - +from django.shortcuts import redirect from rest_framework.permissions import AllowAny from rest_framework.response import Response from rest_framework.generics import CreateAPIView @@ -40,3 +40,5 @@ class TokenCreateApi(AuthMixin, CreateAPIView): return Response(e.as_data(), status=400) except errors.NeedMoreInfoError as e: return Response(e.as_data(), status=200) + except errors.PasswdTooSimple as e: + return redirect(e.url) diff --git a/apps/authentication/backends/api.py b/apps/authentication/backends/api.py index b61798695..ff62677ef 100644 --- a/apps/authentication/backends/api.py +++ b/apps/authentication/backends/api.py @@ -5,14 +5,13 @@ import uuid import time from django.core.cache import cache -from django.conf import settings from django.utils.translation import ugettext as _ from django.utils.six import text_type from django.contrib.auth import get_user_model +from django.contrib.auth.backends import ModelBackend from rest_framework import HTTP_HEADER_ENCODING from rest_framework import authentication, exceptions from common.auth import signature -from rest_framework.authentication import CSRFCheck from common.utils import get_object_or_none, make_signature, http_to_unixtime from ..models import AccessKey, PrivateToken @@ -197,3 +196,10 @@ class SignatureAuthentication(signature.SignatureAuthentication): return user, secret except AccessKey.DoesNotExist: return None, None + + +class SSOAuthentication(ModelBackend): + """ + 什么也不做呀😺 + """ + pass diff --git a/apps/authentication/backends/radius.py b/apps/authentication/backends/radius.py index 6e39b2b79..8edd2124c 100644 --- a/apps/authentication/backends/radius.py +++ b/apps/authentication/backends/radius.py @@ -1,11 +1,11 @@ # -*- coding: utf-8 -*- # +import traceback from django.contrib.auth import get_user_model from radiusauth.backends import RADIUSBackend, RADIUSRealmBackend from django.conf import settings -from pyrad.packet import AccessRequest User = get_user_model() @@ -27,11 +27,22 @@ class CreateUserMixin: user.save() return user + def _perform_radius_auth(self, client, packet): + # TODO: 等待官方库修复这个BUG + try: + return super()._perform_radius_auth(client, packet) + except UnicodeError as e: + import sys + tb = ''.join(traceback.format_exception(*sys.exc_info(), limit=2, chain=False)) + if tb.find("cl.decode") != -1: + return [], False, False + return None + def authenticate(self, *args, **kwargs): # 校验用户时,会传入public_key参数,父类authentication中不接受public_key参数,所以要pop掉 # TODO:需要优化各backend的authenticate方法,django进行调用前会检测各authenticate的参数 kwargs.pop('public_key', None) - return super().authenticate(*args, *kwargs) + return super().authenticate(*args, **kwargs) class RadiusBackend(CreateUserMixin, RADIUSBackend): diff --git a/apps/authentication/const.py b/apps/authentication/const.py new file mode 100644 index 000000000..f5cf56471 --- /dev/null +++ b/apps/authentication/const.py @@ -0,0 +1,2 @@ +RSA_PRIVATE_KEY = 'rsa_private_key' +RSA_PUBLIC_KEY = 'rsa_public_key' diff --git a/apps/authentication/errors.py b/apps/authentication/errors.py index 20ec0aedf..26363363e 100644 --- a/apps/authentication/errors.py +++ b/apps/authentication/errors.py @@ -4,6 +4,7 @@ from django.utils.translation import ugettext_lazy as _ from django.urls import reverse from django.conf import settings +from common.exceptions import JMSException from .signals import post_auth_failed from users.utils import ( increase_login_failed_count, get_login_failed_count @@ -205,3 +206,17 @@ class LoginConfirmOtherError(LoginConfirmBaseError): def __init__(self, ticket_id, status): msg = login_confirm_error_msg.format(status) super().__init__(ticket_id=ticket_id, msg=msg) + + +class SSOAuthClosed(JMSException): + default_code = 'sso_auth_closed' + default_detail = _('SSO auth closed') + + +class PasswdTooSimple(JMSException): + default_code = 'passwd_too_simple' + default_detail = _('Your password is too simple, please change it for security') + + def __init__(self, url, *args, **kwargs): + super(PasswdTooSimple, self).__init__(*args, **kwargs) + self.url = url diff --git a/apps/authentication/filters.py b/apps/authentication/filters.py new file mode 100644 index 000000000..30ab8c157 --- /dev/null +++ b/apps/authentication/filters.py @@ -0,0 +1,15 @@ +from rest_framework import filters +from rest_framework.compat import coreapi, coreschema + + +class AuthKeyQueryDeclaration(filters.BaseFilterBackend): + def get_schema_fields(self, view): + return [ + coreapi.Field( + name='authkey', location='query', required=True, type='string', + schema=coreschema.String( + title='authkey', + description='authkey' + ) + ) + ] diff --git a/apps/authentication/forms.py b/apps/authentication/forms.py index 84e923a8e..2f03d935b 100644 --- a/apps/authentication/forms.py +++ b/apps/authentication/forms.py @@ -2,6 +2,7 @@ # from django import forms +from django.conf import settings from django.utils.translation import gettext_lazy as _ from captcha.fields import CaptchaField @@ -21,9 +22,24 @@ class UserLoginForm(forms.Form): ) -class UserLoginCaptchaForm(UserLoginForm): +class UserCheckOtpCodeForm(forms.Form): + otp_code = forms.CharField(label=_('MFA code'), max_length=6) + + +class CaptchaMixin(forms.Form): captcha = CaptchaField() -class UserCheckOtpCodeForm(forms.Form): - otp_code = forms.CharField(label=_('MFA code'), max_length=6) +class ChallengeMixin(forms.Form): + challenge = forms.CharField(label=_('MFA code'), max_length=6, + required=False) + + +def get_user_login_form_cls(*, captcha=False): + bases = [] + if settings.SECURITY_LOGIN_CAPTCHA_ENABLED and captcha: + bases.append(CaptchaMixin) + if settings.SECURITY_LOGIN_CHALLENGE_ENABLED: + bases.append(ChallengeMixin) + bases.append(UserLoginForm) + return type('UserLoginForm', tuple(bases), {}) diff --git a/apps/authentication/migrations/0004_ssotoken.py b/apps/authentication/migrations/0004_ssotoken.py new file mode 100644 index 000000000..57d2f9805 --- /dev/null +++ b/apps/authentication/migrations/0004_ssotoken.py @@ -0,0 +1,32 @@ +# Generated by Django 2.2.10 on 2020-07-31 08:36 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('authentication', '0003_loginconfirmsetting'), + ] + + operations = [ + migrations.CreateModel( + name='SSOToken', + fields=[ + ('created_by', models.CharField(blank=True, max_length=32, null=True, verbose_name='Created by')), + ('updated_by', models.CharField(blank=True, max_length=32, null=True, verbose_name='Updated by')), + ('date_created', models.DateTimeField(auto_now_add=True, null=True, verbose_name='Date created')), + ('date_updated', models.DateTimeField(auto_now=True, verbose_name='Date updated')), + ('authkey', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='Token')), + ('expired', models.BooleanField(default=False, verbose_name='Expired')), + ('user', models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/apps/authentication/mixins.py b/apps/authentication/mixins.py index 5b3738c98..38a8a852c 100644 --- a/apps/authentication/mixins.py +++ b/apps/authentication/mixins.py @@ -1,7 +1,12 @@ # -*- coding: utf-8 -*- # +from urllib.parse import urlencode +from functools import partial import time + from django.conf import settings +from django.contrib.auth import authenticate +from django.shortcuts import reverse from common.utils import get_object_or_none, get_request_ip, get_logger from users.models import User @@ -9,8 +14,9 @@ from users.utils import ( is_block_login, clean_failed_count ) from . import errors -from .utils import check_user_valid +from .utils import rsa_decrypt from .signals import post_auth_success, post_auth_failed +from .const import RSA_PRIVATE_KEY logger = get_logger(__name__) @@ -50,25 +56,54 @@ class AuthMixin: logger.warn('Ip was blocked' + ': ' + username + ':' + ip) raise errors.BlockLoginError(username=username, ip=ip) - def check_user_auth(self): + def decrypt_passwd(self, raw_passwd): + # 获取解密密钥,对密码进行解密 + rsa_private_key = self.request.session.get(RSA_PRIVATE_KEY) + if rsa_private_key is not None: + try: + return rsa_decrypt(raw_passwd, rsa_private_key) + except Exception as e: + logger.error(e, exc_info=True) + logger.error(f'Decrypt password faild: password[{raw_passwd}] rsa_private_key[{rsa_private_key}]') + return None + return raw_passwd + + def check_user_auth(self, decrypt_passwd=False): self.check_is_block() request = self.request if hasattr(request, 'data'): - username = request.data.get('username', '') - password = request.data.get('password', '') - public_key = request.data.get('public_key', '') + data = request.data else: - username = request.POST.get('username', '') - password = request.POST.get('password', '') - public_key = request.POST.get('public_key', '') - user, error = check_user_valid( - request=request, username=username, password=password, public_key=public_key - ) + data = request.POST + username = data.get('username', '') + password = data.get('password', '') + challenge = data.get('challenge', '') + public_key = data.get('public_key', '') ip = self.get_request_ip() + + CredentialError = partial(errors.CredentialError, username=username, ip=ip, request=request) + + if decrypt_passwd: + password = self.decrypt_passwd(password) + if not password: + raise CredentialError(error=errors.reason_password_decrypt_failed) + + user = authenticate(request, + username=username, + password=password + challenge.strip(), + public_key=public_key) + if not user: - raise errors.CredentialError( - username=username, error=error, ip=ip, request=request - ) + raise CredentialError(error=errors.reason_password_failed) + elif user.is_expired: + raise CredentialError(error=errors.reason_user_inactive) + elif not user.is_active: + raise CredentialError(error=errors.reason_user_inactive) + elif user.password_has_expired: + raise CredentialError(error=errors.reason_password_expired) + + self._check_passwd_is_too_simple(user, password) + clean_failed_count(username, ip) request.session['auth_password'] = 1 request.session['user_id'] = str(user.id) @@ -76,14 +111,30 @@ class AuthMixin: request.session['auth_backend'] = auth_backend return user - def check_user_auth_if_need(self): + @classmethod + def _check_passwd_is_too_simple(cls, user, password): + if user.is_superuser and password == 'admin': + reset_passwd_url = reverse('authentication:reset-password') + query_str = urlencode({ + 'token': user.generate_reset_token() + }) + reset_passwd_url = f'{reset_passwd_url}?{query_str}' + + flash_page_url = reverse('authentication:passwd-too-simple-flash-msg') + query_str = urlencode({ + 'redirect_url': reset_passwd_url + }) + + raise errors.PasswdTooSimple(f'{flash_page_url}?{query_str}') + + def check_user_auth_if_need(self, decrypt_passwd=False): request = self.request if request.session.get('auth_password') and \ request.session.get('user_id'): user = self.get_user_from_session() if user: return user - return self.check_user_auth() + return self.check_user_auth(decrypt_passwd=decrypt_passwd) def check_user_mfa_if_need(self, user): if self.request.session.get('auth_mfa'): @@ -117,7 +168,7 @@ class AuthMixin: def get_ticket_or_create(self, confirm_setting): ticket = self.get_ticket() - if not ticket or ticket.status == ticket.STATUS_CLOSED: + if not ticket or ticket.status == ticket.STATUS.CLOSED: ticket = confirm_setting.create_confirm_ticket(self.request) self.request.session['auth_ticket_id'] = str(ticket.id) return ticket @@ -126,12 +177,12 @@ class AuthMixin: ticket = self.get_ticket() if not ticket: raise errors.LoginConfirmOtherError('', "Not found") - if ticket.status == ticket.STATUS_OPEN: + if ticket.status == ticket.STATUS.OPEN: raise errors.LoginConfirmWaitError(ticket.id) - elif ticket.action == ticket.ACTION_APPROVE: + elif ticket.action == ticket.ACTION.APPROVE: self.request.session["auth_confirm"] = "1" return - elif ticket.action == ticket.ACTION_REJECT: + elif ticket.action == ticket.ACTION.REJECT: raise errors.LoginConfirmOtherError( ticket.id, ticket.get_action_display() ) diff --git a/apps/authentication/models.py b/apps/authentication/models.py index 6a60b3432..208d9c9fb 100644 --- a/apps/authentication/models.py +++ b/apps/authentication/models.py @@ -1,10 +1,13 @@ import uuid -from django.db import models +from functools import partial + from django.utils import timezone from django.utils.translation import ugettext_lazy as _, ugettext as __ from rest_framework.authtoken.models import Token from django.conf import settings +from django.utils.crypto import get_random_string +from common.db import models from common.mixins.models import CommonModelMixin from common.utils import get_object_or_none, get_request_ip, get_ip_city @@ -68,7 +71,7 @@ class LoginConfirmSetting(CommonModelMixin): reviewer = self.reviewers.all() ticket = Ticket.objects.create( user=self.user, title=title, body=body, - type=Ticket.TYPE_LOGIN_CONFIRM, + type=Ticket.TYPE.LOGIN_CONFIRM, ) ticket.assignees.set(reviewer) return ticket @@ -76,3 +79,12 @@ class LoginConfirmSetting(CommonModelMixin): def __str__(self): return '{} confirm'.format(self.user.username) + +class SSOToken(models.JMSBaseModel): + """ + 类似腾讯企业邮的 [单点登录](https://exmail.qq.com/qy_mng_logic/doc#10036) + 出于安全考虑,这里的 `token` 使用一次随即过期。但我们保留每一个生成过的 `token`。 + """ + authkey = models.UUIDField(primary_key=True, default=uuid.uuid4, verbose_name=_('Token')) + expired = models.BooleanField(default=False, verbose_name=_('Expired')) + user = models.ForeignKey('users.User', on_delete=models.PROTECT, verbose_name=_('User'), db_constraint=False) diff --git a/apps/authentication/serializers.py b/apps/authentication/serializers.py index f000c3438..7d666db4c 100644 --- a/apps/authentication/serializers.py +++ b/apps/authentication/serializers.py @@ -5,12 +5,12 @@ from rest_framework import serializers from common.utils import get_object_or_none from users.models import User from users.serializers import UserProfileSerializer -from .models import AccessKey, LoginConfirmSetting +from .models import AccessKey, LoginConfirmSetting, SSOToken __all__ = [ 'AccessKeySerializer', 'OtpVerifySerializer', 'BearerTokenSerializer', - 'MFAChallengeSerializer', 'LoginConfirmSettingSerializer', + 'MFAChallengeSerializer', 'LoginConfirmSettingSerializer', 'SSOTokenSerializer', ] @@ -76,3 +76,9 @@ class LoginConfirmSettingSerializer(serializers.ModelSerializer): model = LoginConfirmSetting fields = ['id', 'user', 'reviewers', 'date_created', 'date_updated'] read_only_fields = ['date_created', 'date_updated'] + + +class SSOTokenSerializer(serializers.Serializer): + username = serializers.CharField(write_only=True) + login_url = serializers.CharField(read_only=True) + next = serializers.CharField(write_only=True, allow_blank=True, required=False, allow_null=True) diff --git a/apps/authentication/templates/authentication/login.html b/apps/authentication/templates/authentication/login.html index 14978e426..a6dec7d9d 100644 --- a/apps/authentication/templates/authentication/login.html +++ b/apps/authentication/templates/authentication/login.html @@ -33,6 +33,16 @@ {% endif %} + {% if form.challenge %} +
+ + {% if form.errors.challenge %} +
+

{{ form.errors.challenge.as_text }}

+
+ {% endif %} +
+ {% endif %}
{{ form.captcha }}
diff --git a/apps/authentication/templates/authentication/xpack_login.html b/apps/authentication/templates/authentication/xpack_login.html index 5929650df..32f12e9b5 100644 --- a/apps/authentication/templates/authentication/xpack_login.html +++ b/apps/authentication/templates/authentication/xpack_login.html @@ -67,22 +67,30 @@
-
- {{ JMS_TITLE }} -
+ {% if form.challenge %} +
+ {% else %} +
+ {% endif %} + {{ JMS_TITLE }} +
{% trans 'Welcome back, please enter username and password to login' %}
-
+
-
+
{% csrf_token %} {% if form.non_field_errors %} -
-

{{ form.non_field_errors.as_text }}

-
+ {% if form.challenge %} +
+ {% else %} +
+ {% endif %} +

{{ form.non_field_errors.as_text }}

+
{% elif form.errors.captcha %}

{% trans 'Captcha invalid' %}

{% else %} @@ -105,6 +113,16 @@
{% endif %}
+ {% if form.challenge %} +
+ + {% if form.errors.challenge %} +
+

{{ form.errors.challenge.as_text }}

+
+ {% endif %} +
+ {% endif %}
{{ form.captcha }}
diff --git a/apps/authentication/urls/api_urls.py b/apps/authentication/urls/api_urls.py index da59711c4..3027fdad0 100644 --- a/apps/authentication/urls/api_urls.py +++ b/apps/authentication/urls/api_urls.py @@ -8,6 +8,7 @@ from .. import api app_name = 'authentication' router = DefaultRouter() router.register('access-keys', api.AccessKeyViewSet, 'access-key') +router.register('sso', api.SSOViewSet, 'sso') urlpatterns = [ diff --git a/apps/authentication/urls/view_urls.py b/apps/authentication/urls/view_urls.py index 12e1fea84..467e32d0d 100644 --- a/apps/authentication/urls/view_urls.py +++ b/apps/authentication/urls/view_urls.py @@ -21,6 +21,7 @@ urlpatterns = [ path('password/forgot/sendmail-success/', users_view.UserForgotPasswordSendmailSuccessView.as_view(), name='forgot-password-sendmail-success'), path('password/reset/', users_view.UserResetPasswordView.as_view(), name='reset-password'), + path('password/too-simple-flash-msg/', views.FlashPasswdTooSimpleMsgView.as_view(), name='passwd-too-simple-flash-msg'), path('password/reset/success/', users_view.UserResetPasswordSuccessView.as_view(), name='reset-password-success'), path('password/verify/', users_view.UserVerifyPasswordView.as_view(), name='user-verify-password'), diff --git a/apps/authentication/utils.py b/apps/authentication/utils.py index 359778cb6..cb697c237 100644 --- a/apps/authentication/utils.py +++ b/apps/authentication/utils.py @@ -4,12 +4,9 @@ import base64 from Crypto.PublicKey import RSA from Crypto.Cipher import PKCS1_v1_5 from Crypto import Random -from django.contrib.auth import authenticate from common.utils import get_logger -from . import errors - logger = get_logger(__file__) @@ -41,33 +38,3 @@ def rsa_decrypt(cipher_text, rsa_private_key=None): cipher = PKCS1_v1_5.new(key) message = cipher.decrypt(base64.b64decode(cipher_text.encode()), 'error').decode() return message - - -def check_user_valid(**kwargs): - password = kwargs.pop('password', None) - public_key = kwargs.pop('public_key', None) - username = kwargs.pop('username', None) - request = kwargs.get('request') - - # 获取解密密钥,对密码进行解密 - rsa_private_key = request.session.get('rsa_private_key') - if rsa_private_key is not None: - try: - password = rsa_decrypt(password, rsa_private_key) - except Exception as e: - logger.error(e, exc_info=True) - logger.error('Need decrypt password => {}'.format(password)) - return None, errors.reason_password_decrypt_failed - - user = authenticate(request, username=username, - password=password, public_key=public_key) - if not user: - return None, errors.reason_password_failed - elif user.is_expired: - return None, errors.reason_user_inactive - elif not user.is_active: - return None, errors.reason_user_inactive - elif user.password_has_expired: - return None, errors.reason_password_expired - - return user, '' diff --git a/apps/authentication/views/login.py b/apps/authentication/views/login.py index 7ef72235b..5493ac3c7 100644 --- a/apps/authentication/views/login.py +++ b/apps/authentication/views/login.py @@ -22,12 +22,15 @@ from common.utils import get_request_ip, get_object_or_none from users.utils import ( redirect_user_first_login_or_index ) -from .. import forms, mixins, errors, utils +from ..const import RSA_PRIVATE_KEY, RSA_PUBLIC_KEY +from .. import mixins, errors, utils +from ..forms import get_user_login_form_cls __all__ = [ 'UserLoginView', 'UserLogoutView', 'UserLoginGuardView', 'UserLoginWaitConfirmView', + 'FlashPasswdTooSimpleMsgView', ] @@ -35,8 +38,6 @@ __all__ = [ @method_decorator(csrf_protect, name='dispatch') @method_decorator(never_cache, name='dispatch') class UserLoginView(mixins.AuthMixin, FormView): - form_class = forms.UserLoginForm - form_class_captcha = forms.UserLoginCaptchaForm key_prefix_captcha = "_LOGIN_INVALID_{}" redirect_field_name = 'next' @@ -82,15 +83,19 @@ class UserLoginView(mixins.AuthMixin, FormView): if not self.request.session.test_cookie_worked(): return HttpResponse(_("Please enable cookies and try again.")) try: - self.check_user_auth() + self.check_user_auth(decrypt_passwd=True) except errors.AuthFailedError as e: form.add_error(None, e.msg) ip = self.get_request_ip() cache.set(self.key_prefix_captcha.format(ip), 1, 3600) - new_form = self.form_class_captcha(data=form.data) + form_cls = get_user_login_form_cls(captcha=True) + new_form = form_cls(data=form.data) new_form._errors = form.errors context = self.get_context_data(form=new_form) return self.render_to_response(context) + except errors.PasswdTooSimple as e: + return redirect(e.url) + self.clear_rsa_key() return self.redirect_to_guard_view() def redirect_to_guard_view(self): @@ -103,18 +108,28 @@ class UserLoginView(mixins.AuthMixin, FormView): def get_form_class(self): ip = get_request_ip(self.request) if cache.get(self.key_prefix_captcha.format(ip)): - return self.form_class_captcha + return get_user_login_form_cls(captcha=True) else: - return self.form_class + return get_user_login_form_cls() + + def clear_rsa_key(self): + self.request.session[RSA_PRIVATE_KEY] = None + self.request.session[RSA_PUBLIC_KEY] = None def get_context_data(self, **kwargs): # 生成加解密密钥对,public_key传递给前端,private_key存入session中供解密使用 - rsa_private_key, rsa_public_key = utils.gen_key_pair() - self.request.session['rsa_private_key'] = rsa_private_key + rsa_private_key = self.request.session.get(RSA_PRIVATE_KEY) + rsa_public_key = self.request.session.get(RSA_PUBLIC_KEY) + if not all((rsa_private_key, rsa_public_key)): + rsa_private_key, rsa_public_key = utils.gen_key_pair() + rsa_public_key = rsa_public_key.replace('\n', '\\n') + self.request.session[RSA_PRIVATE_KEY] = rsa_private_key + self.request.session[RSA_PUBLIC_KEY] = rsa_public_key + context = { 'demo_mode': os.environ.get("DEMO_MODE"), 'AUTH_OPENID': settings.AUTH_OPENID, - 'rsa_public_key': rsa_public_key.replace('\n', '\\n') + 'rsa_public_key': rsa_public_key } kwargs.update(context) return super().get_context_data(**kwargs) @@ -145,6 +160,8 @@ class UserLoginGuardView(mixins.AuthMixin, RedirectView): return self.format_redirect_url(self.login_confirm_url) except errors.MFAUnsetError as e: return e.url + except errors.PasswdTooSimple as e: + return e.url else: auth_login(self.request, user) self.send_auth_signal(success=True, user=user) @@ -216,4 +233,16 @@ class UserLogoutView(TemplateView): return super().get_context_data(**kwargs) +@method_decorator(never_cache, name='dispatch') +class FlashPasswdTooSimpleMsgView(TemplateView): + template_name = 'flash_message_standalone.html' + def get(self, request, *args, **kwargs): + context = { + 'title': _('Please change your password'), + 'messages': _('Your password is too simple, please change it for security'), + 'interval': 5, + 'redirect_url': request.GET.get('redirect_url'), + 'auto_redirect': True, + } + return self.render_to_response(context) diff --git a/apps/common/const/choices.py b/apps/common/const/choices.py new file mode 100644 index 000000000..8de0c5fc8 --- /dev/null +++ b/apps/common/const/choices.py @@ -0,0 +1,8 @@ +from django.utils.translation import ugettext_lazy as _ + +from common.db.models import ChoiceSet + + +ADMIN = 'Admin' +USER = 'User' +AUDITOR = 'Auditor' diff --git a/apps/common/db/aggregates.py b/apps/common/db/aggregates.py index 081c1fea8..e04390299 100644 --- a/apps/common/db/aggregates.py +++ b/apps/common/db/aggregates.py @@ -3,10 +3,10 @@ from django.db.models import Aggregate class GroupConcat(Aggregate): function = 'GROUP_CONCAT' - template = '%(function)s(%(distinct)s %(expressions)s %(order_by)s %(separator))' + template = '%(function)s(%(expressions)s %(order_by)s %(separator)s)' allow_distinct = False - def __init__(self, expression, distinct=False, order_by=None, separator=',', **extra): + def __init__(self, expression, order_by=None, separator=',', **extra): order_by_clause = '' if order_by is not None: order = 'ASC' @@ -21,8 +21,7 @@ class GroupConcat(Aggregate): super().__init__( expression, - distinct='DISTINCT' if distinct else '', order_by=order_by_clause, - separator=f'SEPARATOR {separator}', + separator=f"SEPARATOR '{separator}'", **extra ) diff --git a/apps/common/db/models.py b/apps/common/db/models.py new file mode 100644 index 000000000..502df31e9 --- /dev/null +++ b/apps/common/db/models.py @@ -0,0 +1,84 @@ +""" +此文件作为 `django.db.models` 的 shortcut + +这样做的优点与缺点为: +优点: + - 包命名都统一为 `models` + - 用户在使用的时候只导入本文件即可 +缺点: + - 此文件中添加代码的时候,注意不要跟 `django.db.models` 中的命名冲突 +""" + +import uuid + +from django.db.models import * +from django.db.models.functions import Concat +from django.utils.translation import ugettext_lazy as _ + + +class Choice(str): + def __new__(cls, value, label=''): # `deepcopy` 的时候不会传 `label` + self = super().__new__(cls, value) + self.label = label + return self + + +class ChoiceSetType(type): + def __new__(cls, name, bases, attrs): + _choices = [] + collected = set() + new_attrs = {} + for k, v in attrs.items(): + if isinstance(v, tuple): + v = Choice(*v) + assert v not in collected, 'Cannot be defined repeatedly' + _choices.append(v) + collected.add(v) + new_attrs[k] = v + for base in bases: + if hasattr(base, '_choices'): + for c in base._choices: + if c not in collected: + _choices.append(c) + collected.add(c) + new_attrs['_choices'] = _choices + new_attrs['_choices_dict'] = {c: c.label for c in _choices} + return type.__new__(cls, name, bases, new_attrs) + + def __contains__(self, item): + return self._choices_dict.__contains__(item) + + def __getitem__(self, item): + return self._choices_dict.__getitem__(item) + + def get(self, item, default=None): + return self._choices_dict.get(item, default) + + @property + def choices(self): + return [(c, c.label) for c in self._choices] + + +class ChoiceSet(metaclass=ChoiceSetType): + choices = None # 用于 Django Model 中的 choices 配置, 为了代码提示在此声明 + + +class JMSBaseModel(Model): + created_by = CharField(max_length=32, null=True, blank=True, verbose_name=_('Created by')) + updated_by = CharField(max_length=32, null=True, blank=True, verbose_name=_('Updated by')) + date_created = DateTimeField(auto_now_add=True, null=True, blank=True, verbose_name=_('Date created')) + date_updated = DateTimeField(auto_now=True, verbose_name=_('Date updated')) + + class Meta: + abstract = True + + +class JMSModel(JMSBaseModel): + id = UUIDField(default=uuid.uuid4, primary_key=True) + + class Meta: + abstract = True + + +def concated_display(name1, name2): + return Concat(F(name1), Value('('), F(name2), Value(')')) diff --git a/apps/common/drf/api.py b/apps/common/drf/api.py index 523689e72..febd4467e 100644 --- a/apps/common/drf/api.py +++ b/apps/common/drf/api.py @@ -1,11 +1,42 @@ from rest_framework.viewsets import GenericViewSet, ModelViewSet +from rest_framework_bulk import BulkModelViewSet -from ..mixins.api import SerializerMixin2, QuerySetMixin, ExtraFilterFieldsMixin +from ..mixins.api import ( + SerializerMixin2, QuerySetMixin, ExtraFilterFieldsMixin, PaginatedResponseMixin, + RelationMixin, AllowBulkDestoryMixin +) -class JmsGenericViewSet(SerializerMixin2, QuerySetMixin, ExtraFilterFieldsMixin, GenericViewSet): +class JmsGenericViewSet(SerializerMixin2, + QuerySetMixin, + ExtraFilterFieldsMixin, + PaginatedResponseMixin, + GenericViewSet): pass -class JMSModelViewSet(SerializerMixin2, QuerySetMixin, ExtraFilterFieldsMixin, ModelViewSet): +class JMSModelViewSet(SerializerMixin2, + QuerySetMixin, + ExtraFilterFieldsMixin, + PaginatedResponseMixin, + ModelViewSet): + pass + + +class JMSBulkModelViewSet(SerializerMixin2, + QuerySetMixin, + ExtraFilterFieldsMixin, + PaginatedResponseMixin, + AllowBulkDestoryMixin, + BulkModelViewSet): + pass + + +class JMSBulkRelationModelViewSet(SerializerMixin2, + QuerySetMixin, + ExtraFilterFieldsMixin, + PaginatedResponseMixin, + RelationMixin, + AllowBulkDestoryMixin, + BulkModelViewSet): pass diff --git a/apps/common/drf/exc_handlers.py b/apps/common/drf/exc_handlers.py new file mode 100644 index 000000000..8515c95ec --- /dev/null +++ b/apps/common/drf/exc_handlers.py @@ -0,0 +1,45 @@ +from django.core.exceptions import PermissionDenied, ObjectDoesNotExist as DJObjectDoesNotExist +from django.http import Http404 +from django.utils.translation import gettext + +from rest_framework import exceptions +from rest_framework.views import set_rollback +from rest_framework.response import Response + +from common.exceptions import JMSObjectDoesNotExist + + +def extract_object_name(exc, index=0): + """ + `index` 是从 0 开始数的, 比如: + `No User matches the given query.` + 提取 `User`,`index=1` + """ + (msg, *_) = exc.args + return gettext(msg.split(sep=' ', maxsplit=index + 1)[index]) + + +def common_exception_handler(exc, context): + if isinstance(exc, Http404): + exc = JMSObjectDoesNotExist(object_name=extract_object_name(exc, 1)) + elif isinstance(exc, PermissionDenied): + exc = exceptions.PermissionDenied() + elif isinstance(exc, DJObjectDoesNotExist): + exc = JMSObjectDoesNotExist(object_name=extract_object_name(exc, 0)) + + if isinstance(exc, exceptions.APIException): + headers = {} + if getattr(exc, 'auth_header', None): + headers['WWW-Authenticate'] = exc.auth_header + if getattr(exc, 'wait', None): + headers['Retry-After'] = '%d' % exc.wait + + if isinstance(exc.detail, (list, dict)): + data = exc.detail + else: + data = {'detail': exc.detail} + + set_rollback() + return Response(data, status=exc.status_code, headers=headers) + + return None diff --git a/apps/common/drf/fields.py b/apps/common/drf/fields.py new file mode 100644 index 000000000..e3b333d56 --- /dev/null +++ b/apps/common/drf/fields.py @@ -0,0 +1,43 @@ +from uuid import UUID + +from rest_framework.fields import get_attribute +from rest_framework.relations import ManyRelatedField, PrimaryKeyRelatedField, MANY_RELATION_KWARGS + + +class GroupConcatedManyRelatedField(ManyRelatedField): + def get_attribute(self, instance): + if hasattr(instance, 'pk') and instance.pk is None: + return [] + + attr = self.source_attrs[-1] + + # `gc` 是 `GroupConcat` 的缩写 + gc_attr = f'gc_{attr}' + if hasattr(instance, gc_attr): + gc_value = getattr(instance, gc_attr) + if isinstance(gc_value, str): + return [UUID(pk) for pk in set(gc_value.split(','))] + else: + return '' + + relationship = get_attribute(instance, self.source_attrs) + return relationship.all() if hasattr(relationship, 'all') else relationship + + +class GroupConcatedPrimaryKeyRelatedField(PrimaryKeyRelatedField): + @classmethod + def many_init(cls, *args, **kwargs): + list_kwargs = {'child_relation': cls(*args, **kwargs)} + for key in kwargs: + if key in MANY_RELATION_KWARGS: + list_kwargs[key] = kwargs[key] + return GroupConcatedManyRelatedField(**list_kwargs) + + def to_representation(self, value): + if self.pk_field is not None: + return self.pk_field.to_representation(value.pk) + + if hasattr(value, 'pk'): + return value.pk + else: + return value diff --git a/apps/common/drf/parsers/csv.py b/apps/common/drf/parsers/csv.py index 58b3f7bc2..de0d14ea7 100644 --- a/apps/common/drf/parsers/csv.py +++ b/apps/common/drf/parsers/csv.py @@ -47,9 +47,9 @@ class JMSCSVParser(BaseParser): yield row @staticmethod - def _get_fields_map(serializer): + def _get_fields_map(serializer_cls): fields_map = {} - fields = serializer.fields + fields = serializer_cls().fields fields_map.update({v.label: k for k, v in fields.items()}) fields_map.update({k: k for k, _ in fields.items()}) return fields_map @@ -101,7 +101,7 @@ class JMSCSVParser(BaseParser): try: view = parser_context['view'] meta = view.request.META - serializer = view.get_serializer() + serializer_cls = view.get_serializer_class() except Exception as e: logger.debug(e, exc_info=True) raise ParseError('The resource does not support imports!') @@ -121,7 +121,7 @@ class JMSCSVParser(BaseParser): rows = self._gen_rows(binary, charset=encoding) header = next(rows) - fields_map = self._get_fields_map(serializer) + fields_map = self._get_fields_map(serializer_cls) header = [fields_map.get(name.strip('*'), '') for name in header] data = [] diff --git a/apps/common/drf/serializers.py b/apps/common/drf/serializers.py index bd92415a1..e767c32aa 100644 --- a/apps/common/drf/serializers.py +++ b/apps/common/drf/serializers.py @@ -1,5 +1,25 @@ from rest_framework.serializers import Serializer +from rest_framework.serializers import ModelSerializer +from rest_framework import serializers +from rest_framework_bulk.serializers import BulkListSerializer + +from common.mixins.serializers import BulkSerializerMixin +from common.mixins import BulkListSerializerMixin + +__all__ = ['EmptySerializer', 'BulkModelSerializer'] class EmptySerializer(Serializer): pass + + +class BulkModelSerializer(BulkSerializerMixin, ModelSerializer): + pass + + +class AdaptedBulkListSerializer(BulkListSerializerMixin, BulkListSerializer): + pass + + +class CeleryTaskSerializer(serializers.Serializer): + task = serializers.CharField(read_only=True) diff --git a/apps/common/exceptions.py b/apps/common/exceptions.py index e95cc2801..ded24374a 100644 --- a/apps/common/exceptions.py +++ b/apps/common/exceptions.py @@ -1,7 +1,20 @@ # -*- coding: utf-8 -*- # +from django.utils.translation import gettext_lazy as _ from rest_framework.exceptions import APIException +from rest_framework import status class JMSException(APIException): - pass + status_code = status.HTTP_400_BAD_REQUEST + + +class JMSObjectDoesNotExist(APIException): + status_code = status.HTTP_404_NOT_FOUND + default_code = 'object_does_not_exist' + default_detail = _('%s object does not exist.') + + def __init__(self, detail=None, code=None, object_name=None): + if detail is None and object_name: + detail = self.default_detail % object_name + super(JMSObjectDoesNotExist, self).__init__(detail=detail, code=code) diff --git a/apps/common/mixins/api.py b/apps/common/mixins/api.py index a58e8b079..3e9aea665 100644 --- a/apps/common/mixins/api.py +++ b/apps/common/mixins/api.py @@ -11,6 +11,8 @@ from django.core.cache import cache from django.http import JsonResponse from rest_framework.response import Response from rest_framework.settings import api_settings +from rest_framework import status +from rest_framework_bulk.drf3.mixins import BulkDestroyModelMixin from common.drf.filters import IDSpmFilter, CustomFilter, IDInFilter from ..utils import lazyproperty @@ -67,6 +69,17 @@ class ExtraFilterFieldsMixin: return queryset +class PaginatedResponseMixin: + def get_paginated_response_with_query_set(self, queryset): + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) + + class CommonApiMixin(SerializerMixin, ExtraFilterFieldsMixin): pass @@ -212,10 +225,11 @@ class RelationMixin: self.through = getattr(self.m2m_field.model, self.m2m_field.attname).through def get_queryset(self): + # 注意,此处拦截了 `get_queryset` 没有 `super` queryset = self.through.objects.all() return queryset - def send_post_add_signal(self, instances): + def send_m2m_changed_signal(self, instances, action): if not isinstance(instances, list): instances = [instances] @@ -228,13 +242,17 @@ class RelationMixin: for from_obj, to_ids in from_to_mapper.items(): m2m_changed.send( - sender=self.through, instance=from_obj, action='post_add', + sender=self.through, instance=from_obj, action=action, reverse=False, model=self.to_model, pk_set=to_ids ) def perform_create(self, serializer): instance = serializer.save() - self.send_post_add_signal(instance) + self.send_m2m_changed_signal(instance, 'post_add') + + def perform_destroy(self, instance): + instance.delete() + self.send_m2m_changed_signal(instance, 'post_remove') class SerializerMixin2: @@ -264,3 +282,12 @@ class QuerySetMixin: queryset = serializer_class.setup_eager_loading(queryset) return queryset + + +class AllowBulkDestoryMixin: + def allow_bulk_destroy(self, qs, filtered): + """ + 我们规定,批量删除的情况必须用 `id` 指定要删除的数据。 + """ + query = str(filtered.query) + return '`id` IN (' in query or '`id` =' in query diff --git a/apps/common/mixins/serializers.py b/apps/common/mixins/serializers.py index 5c3a243cf..d9df17e1d 100644 --- a/apps/common/mixins/serializers.py +++ b/apps/common/mixins/serializers.py @@ -8,7 +8,6 @@ from rest_framework.utils import html from rest_framework.settings import api_settings from rest_framework.exceptions import ValidationError from rest_framework.fields import SkipField, empty - __all__ = ['BulkSerializerMixin', 'BulkListSerializerMixin', 'CommonSerializerMixin', 'CommonBulkSerializerMixin'] @@ -50,6 +49,15 @@ class BulkSerializerMixin(object): self.initial_data = data return super().run_validation(data) + @classmethod + def many_init(cls, *args, **kwargs): + meta = getattr(cls, 'Meta', None) + assert meta is not None, 'Must have `Meta`' + if not hasattr(meta, 'list_serializer_class'): + from common.drf.serializers import AdaptedBulkListSerializer + meta.list_serializer_class = AdaptedBulkListSerializer + return super(BulkSerializerMixin, cls).many_init(*args, **kwargs) + class BulkListSerializerMixin(object): """ diff --git a/apps/common/serializers.py b/apps/common/serializers.py index eb657b4cc..971060641 100644 --- a/apps/common/serializers.py +++ b/apps/common/serializers.py @@ -1,14 +1,6 @@ -# -*- coding: utf-8 -*- -# +""" +老的代码统一到 `apps/common/drf/serializers.py` 中, +之后此文件废弃 +""" -from rest_framework_bulk.serializers import BulkListSerializer -from rest_framework import serializers -from .mixins import BulkListSerializerMixin - - -class AdaptedBulkListSerializer(BulkListSerializerMixin, BulkListSerializer): - pass - - -class CeleryTaskSerializer(serializers.Serializer): - task = serializers.CharField(read_only=True) +from common.drf.serializers import AdaptedBulkListSerializer, CeleryTaskSerializer diff --git a/apps/common/utils/common.py b/apps/common/utils/common.py index 77a48dc60..fc9137814 100644 --- a/apps/common/utils/common.py +++ b/apps/common/utils/common.py @@ -11,6 +11,8 @@ import time import ipaddress import psutil +from .timezone import dt_formater + UUID_PATTERN = re.compile(r'\w{8}(-\w{4}){3}-\w{12}') ipip_db = None diff --git a/apps/common/utils/django.py b/apps/common/utils/django.py index 50c3f0ea1..b2a919401 100644 --- a/apps/common/utils/django.py +++ b/apps/common/utils/django.py @@ -2,7 +2,6 @@ # import re from django.shortcuts import reverse as dj_reverse -from django.db.models import Subquery, QuerySet from django.conf import settings from django.utils import timezone diff --git a/apps/common/utils/timezone.py b/apps/common/utils/timezone.py new file mode 100644 index 000000000..2b8779bae --- /dev/null +++ b/apps/common/utils/timezone.py @@ -0,0 +1,33 @@ +import datetime + +import pytz +from django.utils import timezone as dj_timezone +from rest_framework.fields import DateTimeField + +max = datetime.datetime.max.replace(tzinfo=datetime.timezone.utc) + + +def astimezone(dt: datetime.datetime, tzinfo: pytz.tzinfo.DstTzInfo): + assert dj_timezone.is_aware(dt) + return tzinfo.normalize(dt.astimezone(tzinfo)) + + +def as_china_cst(dt: datetime.datetime): + return astimezone(dt, pytz.timezone('Asia/Shanghai')) + + +def as_current_tz(dt: datetime.datetime): + return astimezone(dt, dj_timezone.get_current_timezone()) + + +def utcnow(): + return dj_timezone.now() + + +def now(): + return as_current_tz(utcnow()) + + +_rest_dt_field = DateTimeField() +dt_parser = _rest_dt_field.to_internal_value +dt_formater = _rest_dt_field.to_representation diff --git a/apps/jumpserver/api.py b/apps/jumpserver/api.py index 9558a92df..026a90b9a 100644 --- a/apps/jumpserver/api.py +++ b/apps/jumpserver/api.py @@ -128,7 +128,7 @@ class DatesLoginMetricMixin: @lazyproperty def dates_total_count_inactive_users(self): - total = current_org.get_org_members().count() + total = current_org.get_members().count() active = self.dates_total_count_active_users count = total - active if count < 0: @@ -137,7 +137,7 @@ class DatesLoginMetricMixin: @lazyproperty def dates_total_count_disabled_users(self): - return current_org.get_org_members().filter(is_active=False).count() + return current_org.get_members().filter(is_active=False).count() @lazyproperty def dates_total_count_active_assets(self): @@ -207,7 +207,7 @@ class DatesLoginMetricMixin: class TotalCountMixin: @staticmethod def get_total_count_users(): - return current_org.get_org_members().count() + return current_org.get_members().count() @staticmethod def get_total_count_assets(): diff --git a/apps/jumpserver/conf.py b/apps/jumpserver/conf.py index 173d134b2..3d8b6098f 100644 --- a/apps/jumpserver/conf.py +++ b/apps/jumpserver/conf.py @@ -211,6 +211,9 @@ class Config(dict): 'CAS_LOGOUT_COMPLETELY': True, 'CAS_VERSION': 3, + 'AUTH_SSO': False, + 'AUTH_SSO_AUTHKEY_TTL': 60 * 15, + 'OTP_VALID_WINDOW': 2, 'OTP_ISSUER_NAME': 'JumpServer', 'EMAIL_SUFFIX': 'jumpserver.org', @@ -238,6 +241,8 @@ class Config(dict): 'SECURITY_PASSWORD_LOWER_CASE': False, 'SECURITY_PASSWORD_NUMBER': False, 'SECURITY_PASSWORD_SPECIAL_CHAR': False, + 'SECURITY_LOGIN_CHALLENGE_ENABLED': False, + 'SECURITY_LOGIN_CAPTCHA_ENABLED': True, 'HTTP_BIND_HOST': '0.0.0.0', 'HTTP_LISTEN_PORT': 8080, @@ -438,6 +443,8 @@ class DynamicConfig: backends.insert(0, 'jms_oidc_rp.backends.OIDCAuthCodeBackend') if self.static_config.get('AUTH_RADIUS'): backends.insert(0, 'authentication.backends.radius.RadiusBackend') + if self.static_config.get('AUTH_SSO'): + backends.insert(0, 'authentication.backends.api.SSOAuthentication') return backends def XPACK_LICENSE_IS_VALID(self): diff --git a/apps/jumpserver/settings/auth.py b/apps/jumpserver/settings/auth.py index f96498e0e..92c0d82f1 100644 --- a/apps/jumpserver/settings/auth.py +++ b/apps/jumpserver/settings/auth.py @@ -92,7 +92,11 @@ CAS_LOGGED_MSG = None CAS_LOGOUT_COMPLETELY = CONFIG.CAS_LOGOUT_COMPLETELY CAS_VERSION = CONFIG.CAS_VERSION CAS_ROOT_PROXIED_AS = CONFIG.CAS_ROOT_PROXIED_AS +CAS_CHECK_NEXT = lambda: lambda _next_page: True +# SSO Auth +AUTH_SSO = CONFIG.AUTH_SSO +AUTH_SSO_AUTHKEY_TTL = CONFIG.AUTH_SSO_AUTHKEY_TTL # Other setting TOKEN_EXPIRATION = CONFIG.TOKEN_EXPIRATION diff --git a/apps/jumpserver/settings/custom.py b/apps/jumpserver/settings/custom.py index 1e552345b..b691d3ce5 100644 --- a/apps/jumpserver/settings/custom.py +++ b/apps/jumpserver/settings/custom.py @@ -41,6 +41,8 @@ SECURITY_PASSWORD_RULES = [ SECURITY_MFA_VERIFY_TTL = CONFIG.SECURITY_MFA_VERIFY_TTL SECURITY_VIEW_AUTH_NEED_MFA = CONFIG.SECURITY_VIEW_AUTH_NEED_MFA SECURITY_SERVICE_ACCOUNT_REGISTRATION = DYNAMIC.SECURITY_SERVICE_ACCOUNT_REGISTRATION +SECURITY_LOGIN_CAPTCHA_ENABLED = CONFIG.SECURITY_LOGIN_CAPTCHA_ENABLED +SECURITY_LOGIN_CHALLENGE_ENABLED = CONFIG.SECURITY_LOGIN_CHALLENGE_ENABLED # Terminal other setting TERMINAL_PASSWORD_AUTH = DYNAMIC.TERMINAL_PASSWORD_AUTH @@ -94,3 +96,5 @@ XPACK_LICENSE_IS_VALID = DYNAMIC.XPACK_LICENSE_IS_VALID LOGO_URLS = DYNAMIC.LOGO_URLS CHANGE_AUTH_PLAN_SECURE_MODE_ENABLED = CONFIG.CHANGE_AUTH_PLAN_SECURE_MODE_ENABLED + +DATETIME_DISPLAY_FORMAT = '%Y-%m-%d %H:%M:%S' diff --git a/apps/jumpserver/settings/libs.py b/apps/jumpserver/settings/libs.py index eb07e299d..9e4b56e21 100644 --- a/apps/jumpserver/settings/libs.py +++ b/apps/jumpserver/settings/libs.py @@ -40,6 +40,7 @@ REST_FRAMEWORK = { 'DATETIME_FORMAT': '%Y-%m-%d %H:%M:%S %z', 'DATETIME_INPUT_FORMATS': ['iso-8601', '%Y-%m-%d %H:%M:%S %z'], 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination', + 'EXCEPTION_HANDLER': 'common.drf.exc_handlers.common_exception_handler', # 'PAGE_SIZE': 100, # 'MAX_PAGE_SIZE': 5000 diff --git a/apps/jumpserver/urls.py b/apps/jumpserver/urls.py index 60c04676f..d39eb56c5 100644 --- a/apps/jumpserver/urls.py +++ b/apps/jumpserver/urls.py @@ -75,8 +75,8 @@ if settings.DEBUG: urlpatterns += [ re_path('^api/swagger(?P\.json|\.yaml)$', views.get_swagger_view().without_ui(cache_timeout=1), name='schema-json'), - path('api/docs/', views.get_swagger_view().with_ui('swagger', cache_timeout=1), name="docs"), - path('api/redoc/', views.get_swagger_view().with_ui('redoc', cache_timeout=1), name='redoc'), + re_path('api/docs/?', views.get_swagger_view().with_ui('swagger', cache_timeout=1), name="docs"), + re_path('api/redoc/?', views.get_swagger_view().with_ui('redoc', cache_timeout=1), name='redoc'), re_path('^api/v2/swagger(?P\.json|\.yaml)$', views.get_swagger_view().without_ui(cache_timeout=1), name='schema-json'), diff --git a/apps/locale/zh/LC_MESSAGES/django.mo b/apps/locale/zh/LC_MESSAGES/django.mo index 0337649da..27d124dba 100644 Binary files a/apps/locale/zh/LC_MESSAGES/django.mo and b/apps/locale/zh/LC_MESSAGES/django.mo differ diff --git a/apps/locale/zh/LC_MESSAGES/django.po b/apps/locale/zh/LC_MESSAGES/django.po index c311e0a7f..6a8c43190 100644 --- a/apps/locale/zh/LC_MESSAGES/django.po +++ b/apps/locale/zh/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: JumpServer 0.3.3\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2020-07-15 17:13+0800\n" +"POT-Creation-Date: 2020-08-07 18:48+0800\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: ibuler \n" "Language-Team: JumpServer team\n" @@ -21,15 +21,15 @@ msgstr "" msgid "Custom" msgstr "自定义" -#: applications/models/database_app.py:18 applications/models/remote_app.py:21 -#: assets/models/asset.py:145 assets/models/base.py:232 -#: assets/models/cluster.py:18 assets/models/cmd_filter.py:21 -#: assets/models/domain.py:20 assets/models/group.py:20 -#: assets/models/label.py:18 ops/mixin.py:24 orgs/models.py:12 -#: perms/models/base.py:48 settings/models.py:27 terminal/models.py:26 -#: terminal/models.py:342 terminal/models.py:374 terminal/models.py:411 -#: users/forms/profile.py:20 users/models/group.py:15 users/models/user.py:467 -#: users/templates/users/_select_user_modal.html:13 +#: applications/models/database_app.py:18 applications/models/k8s_app.py:11 +#: applications/models/remote_app.py:21 assets/models/asset.py:145 +#: assets/models/base.py:232 assets/models/cluster.py:18 +#: assets/models/cmd_filter.py:21 assets/models/domain.py:20 +#: assets/models/group.py:20 assets/models/label.py:18 ops/mixin.py:24 +#: orgs/models.py:22 perms/models/base.py:48 settings/models.py:27 +#: terminal/models.py:26 terminal/models.py:342 terminal/models.py:374 +#: terminal/models.py:411 users/forms/profile.py:20 users/models/group.py:15 +#: users/models/user.py:489 users/templates/users/_select_user_modal.html:13 #: users/templates/users/user_asset_permission.html:37 #: users/templates/users/user_asset_permission.html:154 #: users/templates/users/user_database_app_permission.html:36 @@ -46,8 +46,9 @@ msgstr "自定义" msgid "Name" msgstr "名称" -#: applications/models/database_app.py:22 assets/models/cmd_filter.py:51 -#: terminal/models.py:376 terminal/models.py:413 tickets/models/ticket.py:45 +#: applications/models/database_app.py:22 applications/models/k8s_app.py:14 +#: assets/models/cmd_filter.py:52 terminal/models.py:376 terminal/models.py:413 +#: tickets/models/ticket.py:40 #: users/templates/users/user_granted_database_app.html:35 msgid "Type" msgstr "类型" @@ -69,16 +70,16 @@ msgstr "数据库" # msgid "Date created" # msgstr "创建日期" -#: applications/models/database_app.py:33 applications/models/remote_app.py:45 -#: assets/models/asset.py:150 assets/models/asset.py:226 -#: assets/models/base.py:237 assets/models/cluster.py:29 -#: assets/models/cmd_filter.py:23 assets/models/cmd_filter.py:56 -#: assets/models/domain.py:21 assets/models/domain.py:54 -#: assets/models/group.py:23 assets/models/label.py:23 ops/models/adhoc.py:37 -#: orgs/models.py:18 perms/models/base.py:56 settings/models.py:32 -#: terminal/models.py:36 terminal/models.py:381 terminal/models.py:418 -#: users/models/group.py:16 users/models/user.py:500 -#: users/templates/users/user_detail.html:115 +#: applications/models/database_app.py:33 applications/models/k8s_app.py:18 +#: applications/models/remote_app.py:45 assets/models/asset.py:150 +#: assets/models/asset.py:226 assets/models/base.py:237 +#: assets/models/cluster.py:29 assets/models/cmd_filter.py:23 +#: assets/models/cmd_filter.py:57 assets/models/domain.py:21 +#: assets/models/domain.py:54 assets/models/group.py:23 +#: assets/models/label.py:23 ops/models/adhoc.py:37 orgs/models.py:25 +#: perms/models/base.py:56 settings/models.py:32 terminal/models.py:36 +#: terminal/models.py:381 terminal/models.py:418 users/models/group.py:16 +#: users/models/user.py:522 users/templates/users/user_detail.html:115 #: users/templates/users/user_granted_database_app.html:38 #: users/templates/users/user_granted_remote_app.html:37 #: users/templates/users/user_group_detail.html:62 @@ -99,12 +100,25 @@ msgstr "备注" msgid "DatabaseApp" msgstr "数据库应用" +#: applications/models/k8s_app.py:9 +msgid "Kubernetes" +msgstr "" + +#: applications/models/k8s_app.py:16 assets/models/cluster.py:40 +msgid "Cluster" +msgstr "集群" + +#: applications/models/k8s_app.py:26 perms/models/k8s_app_permission.py:18 +#: perms/utils/k8s_app_permission.py:70 +msgid "KubernetesApp" +msgstr "Kubernetes应用" + #: applications/models/remote_app.py:23 assets/models/asset.py:352 #: assets/models/authbook.py:26 assets/models/gathered_user.py:14 #: assets/serializers/admin_user.py:32 assets/serializers/asset_user.py:47 #: assets/serializers/asset_user.py:84 assets/serializers/system_user.py:44 #: assets/serializers/system_user.py:176 audits/models.py:38 -#: perms/forms/asset_permission.py:89 perms/models/asset_permission.py:80 +#: perms/forms/asset_permission.py:89 perms/models/asset_permission.py:90 #: templates/index.html:82 terminal/backends/command/models.py:19 #: terminal/backends/command/serializers.py:13 terminal/models.py:187 #: users/templates/users/user_asset_permission.html:40 @@ -130,10 +144,11 @@ msgstr "参数" #: applications/models/remote_app.py:39 assets/models/asset.py:224 #: assets/models/base.py:240 assets/models/cluster.py:28 -#: assets/models/cmd_filter.py:26 assets/models/cmd_filter.py:59 -#: assets/models/group.py:21 common/mixins/models.py:49 orgs/models.py:16 -#: perms/models/base.py:54 users/models/user.py:508 -#: users/serializers/group.py:35 users/templates/users/user_detail.html:97 +#: assets/models/cmd_filter.py:26 assets/models/cmd_filter.py:60 +#: assets/models/group.py:21 common/db/models.py:67 common/mixins/models.py:49 +#: orgs/models.py:23 orgs/models.py:326 perms/models/base.py:54 +#: users/models/user.py:530 users/serializers/group.py:35 +#: users/templates/users/user_detail.html:97 #: xpack/plugins/change_auth_plan/models.py:81 xpack/plugins/cloud/models.py:56 #: xpack/plugins/cloud/models.py:146 xpack/plugins/gathered_user/models.py:30 msgid "Created by" @@ -144,10 +159,10 @@ msgstr "创建者" #: applications/models/remote_app.py:42 assets/models/asset.py:225 #: assets/models/base.py:238 assets/models/cluster.py:26 #: assets/models/domain.py:23 assets/models/gathered_user.py:19 -#: assets/models/group.py:22 assets/models/label.py:25 +#: assets/models/group.py:22 assets/models/label.py:25 common/db/models.py:69 #: common/mixins/models.py:50 ops/models/adhoc.py:38 ops/models/command.py:27 -#: orgs/models.py:17 perms/models/base.py:55 users/models/group.py:18 -#: users/templates/users/user_group_detail.html:58 +#: orgs/models.py:24 orgs/models.py:324 perms/models/base.py:55 +#: users/models/group.py:18 users/templates/users/user_group_detail.html:58 #: xpack/plugins/cloud/models.py:59 xpack/plugins/cloud/models.py:149 msgid "Date created" msgstr "创建日期" @@ -189,7 +204,7 @@ msgstr "基础" msgid "Charset" msgstr "编码" -#: assets/models/asset.py:148 tickets/models/ticket.py:40 +#: assets/models/asset.py:148 tickets/models/ticket.py:35 msgid "Meta" msgstr "元数据" @@ -211,19 +226,19 @@ msgstr "IP" #: assets/models/asset.py:187 assets/serializers/asset_user.py:45 #: assets/serializers/gathered_user.py:20 settings/serializers/settings.py:51 -#: tickets/serializers/request_asset_perm.py:14 +#: tickets/serializers/request_asset_perm.py:25 #: users/templates/users/_granted_assets.html:25 #: users/templates/users/user_asset_permission.html:157 msgid "Hostname" msgstr "主机名" #: assets/models/asset.py:190 assets/models/domain.py:52 -#: assets/models/user.py:114 terminal/serializers/session.py:29 +#: assets/models/user.py:116 terminal/serializers/session.py:29 msgid "Protocol" msgstr "协议" #: assets/models/asset.py:192 assets/serializers/asset.py:69 -#: perms/serializers/user_permission.py:60 +#: perms/serializers/user_permission.py:71 msgid "Protocols" msgstr "协议组" @@ -232,8 +247,8 @@ msgstr "协议组" msgid "Domain" msgstr "网域" -#: assets/models/asset.py:195 assets/models/user.py:109 -#: perms/models/asset_permission.py:81 +#: assets/models/asset.py:195 assets/models/user.py:111 +#: perms/models/asset_permission.py:91 #: xpack/plugins/change_auth_plan/models.py:56 #: xpack/plugins/gathered_user/models.py:24 msgid "Nodes" @@ -241,13 +256,13 @@ msgstr "节点" #: assets/models/asset.py:196 assets/models/cmd_filter.py:22 #: assets/models/domain.py:55 assets/models/label.py:22 -#: authentication/models.py:45 +#: authentication/models.py:48 msgid "Is active" msgstr "激活" #: assets/models/asset.py:199 assets/models/cluster.py:19 #: assets/models/user.py:65 templates/_nav.html:44 -#: xpack/plugins/cloud/models.py:133 xpack/plugins/cloud/serializers.py:82 +#: xpack/plugins/cloud/models.py:133 xpack/plugins/cloud/serializers.py:83 msgid "Admin user" msgstr "管理用户" @@ -336,10 +351,10 @@ msgid "AuthBook" msgstr "" #: assets/models/base.py:233 assets/models/gathered_user.py:15 -#: audits/models.py:99 authentication/forms.py:10 +#: audits/models.py:99 authentication/forms.py:11 #: authentication/templates/authentication/login.html:21 -#: authentication/templates/authentication/xpack_login.html:93 -#: ops/models/adhoc.py:148 users/forms/profile.py:19 users/models/user.py:465 +#: authentication/templates/authentication/xpack_login.html:101 +#: ops/models/adhoc.py:148 users/forms/profile.py:19 users/models/user.py:487 #: users/templates/users/_select_user_modal.html:14 #: users/templates/users/user_detail.html:53 #: users/templates/users/user_list.html:15 @@ -350,9 +365,9 @@ msgid "Username" msgstr "用户名" #: assets/models/base.py:234 assets/serializers/asset_user.py:71 -#: authentication/forms.py:12 +#: authentication/forms.py:13 #: authentication/templates/authentication/login.html:29 -#: authentication/templates/authentication/xpack_login.html:101 +#: authentication/templates/authentication/xpack_login.html:109 #: users/forms/user.py:22 users/forms/user.py:193 #: users/templates/users/user_otp_check_password.html:13 #: users/templates/users/user_password_update.html:43 @@ -379,7 +394,8 @@ msgid "SSH public key" msgstr "SSH公钥" #: assets/models/base.py:239 assets/models/gathered_user.py:20 -#: common/mixins/models.py:51 ops/models/adhoc.py:39 +#: common/db/models.py:70 common/mixins/models.py:51 ops/models/adhoc.py:39 +#: orgs/models.py:325 msgid "Date updated" msgstr "更新日期" @@ -391,7 +407,7 @@ msgstr "带宽" msgid "Contact" msgstr "联系人" -#: assets/models/cluster.py:22 users/models/user.py:486 +#: assets/models/cluster.py:22 users/models/user.py:508 #: users/templates/users/user_detail.html:62 msgid "Phone" msgstr "手机" @@ -417,7 +433,7 @@ msgid "Default" msgstr "默认" #: assets/models/cluster.py:36 assets/models/label.py:14 -#: users/models/user.py:627 +#: users/models/user.py:655 msgid "System" msgstr "系统" @@ -425,10 +441,6 @@ msgstr "系统" msgid "Default Cluster" msgstr "默认Cluster" -#: assets/models/cluster.py:40 -msgid "Cluster" -msgstr "集群" - #: assets/models/cluster.py:56 msgid "Beijing unicom" msgstr "北京联通" @@ -441,52 +453,52 @@ msgstr "北京电信" msgid "BGP full netcom" msgstr "BGP全网通" -#: assets/models/cmd_filter.py:32 assets/models/user.py:119 +#: assets/models/cmd_filter.py:33 assets/models/user.py:121 msgid "Command filter" msgstr "命令过滤器" -#: assets/models/cmd_filter.py:39 +#: assets/models/cmd_filter.py:40 msgid "Regex" msgstr "正则表达式" -#: assets/models/cmd_filter.py:40 ops/models/command.py:23 +#: assets/models/cmd_filter.py:41 ops/models/command.py:23 #: terminal/backends/command/serializers.py:15 terminal/models.py:196 msgid "Command" msgstr "命令" -#: assets/models/cmd_filter.py:45 +#: assets/models/cmd_filter.py:46 msgid "Deny" msgstr "拒绝" -#: assets/models/cmd_filter.py:46 +#: assets/models/cmd_filter.py:47 msgid "Allow" msgstr "允许" -#: assets/models/cmd_filter.py:50 +#: assets/models/cmd_filter.py:51 msgid "Filter" msgstr "过滤器" -#: assets/models/cmd_filter.py:52 assets/models/user.py:113 +#: assets/models/cmd_filter.py:53 assets/models/user.py:115 msgid "Priority" msgstr "优先级" -#: assets/models/cmd_filter.py:52 +#: assets/models/cmd_filter.py:53 msgid "1-100, the higher will be match first" msgstr "优先级可选范围为1-100,1最低优先级,100最高优先级" -#: assets/models/cmd_filter.py:54 xpack/plugins/license/models.py:29 +#: assets/models/cmd_filter.py:55 xpack/plugins/license/models.py:29 msgid "Content" msgstr "内容" -#: assets/models/cmd_filter.py:54 +#: assets/models/cmd_filter.py:55 msgid "One line one command" msgstr "每行一个命令" -#: assets/models/cmd_filter.py:55 audits/models.py:57 +#: assets/models/cmd_filter.py:56 audits/models.py:57 #: authentication/templates/authentication/_access_key_modal.html:34 #: perms/forms/asset_permission.py:20 -#: tickets/serializers/request_asset_perm.py:54 -#: tickets/serializers/ticket.py:26 +#: tickets/serializers/request_asset_perm.py:64 +#: tickets/serializers/ticket.py:30 #: users/templates/users/_granted_assets.html:29 #: users/templates/users/user_asset_permission.html:44 #: users/templates/users/user_asset_permission.html:79 @@ -497,7 +509,7 @@ msgstr "每行一个命令" msgid "Action" msgstr "动作" -#: assets/models/cmd_filter.py:63 +#: assets/models/cmd_filter.py:64 msgid "Command filter rule" msgstr "命令过滤规则" @@ -534,15 +546,16 @@ msgid "Default asset group" msgstr "默认资产组" #: assets/models/label.py:15 audits/models.py:36 audits/models.py:56 -#: audits/models.py:69 audits/serializers.py:77 authentication/models.py:43 +#: audits/models.py:69 audits/serializers.py:77 authentication/models.py:46 +#: authentication/models.py:90 orgs/models.py:16 orgs/models.py:322 #: perms/forms/asset_permission.py:83 perms/forms/database_app_permission.py:38 #: perms/forms/remote_app_permission.py:40 perms/models/base.py:49 #: templates/index.html:78 terminal/backends/command/models.py:18 #: terminal/backends/command/serializers.py:12 terminal/models.py:185 -#: tickets/models/ticket.py:35 tickets/models/ticket.py:130 -#: tickets/serializers/request_asset_perm.py:55 -#: tickets/serializers/ticket.py:27 users/forms/group.py:15 -#: users/models/user.py:160 users/models/user.py:176 users/models/user.py:615 +#: tickets/models/ticket.py:30 tickets/models/ticket.py:137 +#: tickets/serializers/request_asset_perm.py:65 +#: tickets/serializers/ticket.py:31 users/forms/group.py:15 +#: users/models/user.py:157 users/models/user.py:643 #: users/serializers/group.py:20 #: users/templates/users/user_asset_permission.html:38 #: users/templates/users/user_asset_permission.html:64 @@ -590,65 +603,70 @@ msgstr "键" #: users/templates/users/user_asset_permission.html:41 #: users/templates/users/user_asset_permission.html:73 #: users/templates/users/user_asset_permission.html:158 -#: xpack/plugins/cloud/models.py:129 xpack/plugins/cloud/serializers.py:83 +#: xpack/plugins/cloud/models.py:129 xpack/plugins/cloud/serializers.py:84 msgid "Node" msgstr "节点" -#: assets/models/user.py:105 +#: assets/models/user.py:107 msgid "Automatic login" msgstr "自动登录" -#: assets/models/user.py:106 +#: assets/models/user.py:108 msgid "Manually login" msgstr "手动登录" -#: assets/models/user.py:108 +#: assets/models/user.py:110 msgid "Username same with user" msgstr "用户名与用户相同" -#: assets/models/user.py:110 templates/_nav.html:39 +#: assets/models/user.py:112 templates/_nav.html:39 #: xpack/plugins/change_auth_plan/models.py:52 msgid "Assets" msgstr "资产管理" -#: assets/models/user.py:111 templates/_nav.html:17 +#: assets/models/user.py:113 templates/_nav.html:17 #: users/views/profile/password.py:42 users/views/profile/pubkey.py:36 msgid "Users" msgstr "用户管理" -#: assets/models/user.py:112 users/templates/users/user_group_list.html:90 +#: assets/models/user.py:114 users/templates/users/user_group_list.html:90 #: users/templates/users/user_profile.html:124 msgid "User groups" msgstr "用户组" -#: assets/models/user.py:115 +#: assets/models/user.py:117 msgid "Auto push" msgstr "自动推送" -#: assets/models/user.py:116 +#: assets/models/user.py:118 msgid "Sudo" msgstr "Sudo" -#: assets/models/user.py:117 +#: assets/models/user.py:119 msgid "Shell" msgstr "Shell" -#: assets/models/user.py:118 +#: assets/models/user.py:120 msgid "Login mode" msgstr "登录模式" -#: assets/models/user.py:120 +#: assets/models/user.py:122 msgid "SFTP Root" msgstr "SFTP根路径" -#: assets/models/user.py:195 audits/models.py:39 +#: assets/models/user.py:123 authentication/models.py:88 +msgid "Token" +msgstr "" + +#: assets/models/user.py:198 audits/models.py:39 #: perms/forms/asset_permission.py:95 perms/forms/remote_app_permission.py:49 -#: perms/models/asset_permission.py:82 +#: perms/models/asset_permission.py:92 #: perms/models/database_app_permission.py:22 +#: perms/models/k8s_app_permission.py:22 #: perms/models/remote_app_permission.py:16 templates/_nav.html:45 #: terminal/backends/command/models.py:20 #: terminal/backends/command/serializers.py:14 terminal/models.py:189 -#: tickets/serializers/request_asset_perm.py:16 +#: tickets/serializers/request_asset_perm.py:27 #: users/templates/users/_granted_assets.html:27 #: users/templates/users/user_asset_permission.html:42 #: users/templates/users/user_asset_permission.html:76 @@ -689,7 +707,7 @@ msgstr "协议重复: {}" msgid "Hardware info" msgstr "硬件信息" -#: assets/serializers/asset.py:112 orgs/mixins/serializers.py:27 +#: assets/serializers/asset.py:112 orgs/mixins/serializers.py:26 msgid "Org name" msgstr "组织名称" @@ -707,14 +725,14 @@ msgid "Backend" msgstr "后端" #: assets/serializers/asset_user.py:75 users/forms/profile.py:148 -#: users/models/user.py:497 users/templates/users/user_password_update.html:48 +#: users/models/user.py:519 users/templates/users/user_password_update.html:48 #: users/templates/users/user_profile.html:69 #: users/templates/users/user_profile_update.html:46 #: users/templates/users/user_pubkey_update.html:46 msgid "Public key" msgstr "SSH公钥" -#: assets/serializers/asset_user.py:79 users/models/user.py:494 +#: assets/serializers/asset_user.py:79 users/models/user.py:516 msgid "Private key" msgstr "ssh私钥" @@ -874,7 +892,7 @@ msgstr "没有匹配到资产,结束任务" #: users/templates/users/user_list.html:98 #: users/templates/users/user_remote_app_permission.html:111 msgid "Delete" -msgstr "删除" +msgstr "删除文件" #: audits/models.py:27 msgid "Upload" @@ -919,7 +937,7 @@ msgid "Success" msgstr "成功" #: audits/models.py:43 ops/models/command.py:28 perms/models/base.py:52 -#: terminal/models.py:199 tickets/serializers/request_asset_perm.py:18 +#: terminal/models.py:199 tickets/serializers/request_asset_perm.py:29 #: xpack/plugins/change_auth_plan/models.py:177 #: xpack/plugins/change_auth_plan/models.py:308 #: xpack/plugins/gathered_user/models.py:76 @@ -999,8 +1017,8 @@ msgstr "Agent" #: audits/models.py:104 #: authentication/templates/authentication/_mfa_confirm_modal.html:14 #: authentication/templates/authentication/login_otp.html:6 -#: users/forms/profile.py:52 users/models/user.py:489 -#: users/serializers/user.py:216 users/templates/users/user_detail.html:77 +#: users/forms/profile.py:52 users/models/user.py:511 +#: users/serializers/user.py:240 users/templates/users/user_detail.html:77 #: users/templates/users/user_profile.html:87 msgid "MFA" msgstr "多因子认证" @@ -1010,8 +1028,8 @@ msgstr "多因子认证" msgid "Reason" msgstr "原因" -#: audits/models.py:106 tickets/serializers/request_asset_perm.py:53 -#: tickets/serializers/ticket.py:25 xpack/plugins/cloud/models.py:211 +#: audits/models.py:106 tickets/serializers/request_asset_perm.py:63 +#: tickets/serializers/ticket.py:29 xpack/plugins/cloud/models.py:211 #: xpack/plugins/cloud/models.py:269 msgid "Status" msgstr "状态" @@ -1041,94 +1059,94 @@ msgstr "运行用户" msgid "Code is invalid" msgstr "Code无效" -#: authentication/backends/api.py:53 +#: authentication/backends/api.py:52 msgid "Invalid signature header. No credentials provided." msgstr "" -#: authentication/backends/api.py:56 +#: authentication/backends/api.py:55 msgid "Invalid signature header. Signature string should not contain spaces." msgstr "" -#: authentication/backends/api.py:63 +#: authentication/backends/api.py:62 msgid "Invalid signature header. Format like AccessKeyId:Signature" msgstr "" -#: authentication/backends/api.py:67 +#: authentication/backends/api.py:66 msgid "" "Invalid signature header. Signature string should not contain invalid " "characters." msgstr "" -#: authentication/backends/api.py:87 authentication/backends/api.py:103 +#: authentication/backends/api.py:86 authentication/backends/api.py:102 msgid "Invalid signature." msgstr "" -#: authentication/backends/api.py:94 +#: authentication/backends/api.py:93 msgid "HTTP header: Date not provide or not %a, %d %b %Y %H:%M:%S GMT" msgstr "" -#: authentication/backends/api.py:99 +#: authentication/backends/api.py:98 msgid "Expired, more than 15 minutes" msgstr "" -#: authentication/backends/api.py:106 +#: authentication/backends/api.py:105 msgid "User disabled." msgstr "用户已禁用" -#: authentication/backends/api.py:124 +#: authentication/backends/api.py:123 msgid "Invalid token header. No credentials provided." msgstr "" -#: authentication/backends/api.py:127 +#: authentication/backends/api.py:126 msgid "Invalid token header. Sign string should not contain spaces." msgstr "" -#: authentication/backends/api.py:134 +#: authentication/backends/api.py:133 msgid "" "Invalid token header. Sign string should not contain invalid characters." msgstr "" -#: authentication/backends/api.py:145 +#: authentication/backends/api.py:144 msgid "Invalid token or cache refreshed." msgstr "" -#: authentication/errors.py:22 +#: authentication/errors.py:23 msgid "Username/password check failed" msgstr "用户名/密码 校验失败" -#: authentication/errors.py:23 +#: authentication/errors.py:24 msgid "Password decrypt failed" msgstr "密码解密失败" -#: authentication/errors.py:24 +#: authentication/errors.py:25 msgid "MFA failed" msgstr "多因子认证失败" -#: authentication/errors.py:25 +#: authentication/errors.py:26 msgid "MFA unset" msgstr "多因子认证没有设定" -#: authentication/errors.py:26 +#: authentication/errors.py:27 msgid "Username does not exist" msgstr "用户名不存在" -#: authentication/errors.py:27 +#: authentication/errors.py:28 msgid "Password expired" msgstr "密码已过期" -#: authentication/errors.py:28 +#: authentication/errors.py:29 msgid "Disabled or expired" msgstr "禁用或失效" -#: authentication/errors.py:29 +#: authentication/errors.py:30 msgid "This account is inactive." msgstr "此账户已禁用" -#: authentication/errors.py:39 +#: authentication/errors.py:40 msgid "No session found, check your cookie" msgstr "会话已变更,刷新页面" -#: authentication/errors.py:41 +#: authentication/errors.py:42 #, python-brace-format msgid "" "The username or password you entered is incorrect, please enter it again. " @@ -1138,42 +1156,53 @@ msgstr "" "您输入的用户名或密码不正确,请重新输入。 您还可以尝试 {times_try} 次(账号将" "被临时 锁定 {block_time} 分钟)" -#: authentication/errors.py:47 +#: authentication/errors.py:48 msgid "" "The account has been locked (please contact admin to unlock it or try again " "after {} minutes)" msgstr "账号已被锁定(请联系管理员解锁 或 {}分钟后重试)" -#: authentication/errors.py:50 users/views/profile/otp.py:107 +#: authentication/errors.py:51 users/views/profile/otp.py:107 #: users/views/profile/otp.py:146 users/views/profile/otp.py:166 msgid "MFA code invalid, or ntp sync server time" msgstr "MFA验证码不正确,或者服务器端时间不对" -#: authentication/errors.py:52 +#: authentication/errors.py:53 msgid "MFA required" msgstr "需要多因子认证" -#: authentication/errors.py:53 +#: authentication/errors.py:54 msgid "MFA not set, please set it first" msgstr "多因子认证没有设置,请先完成设置" -#: authentication/errors.py:54 +#: authentication/errors.py:55 msgid "Login confirm required" msgstr "需要登录复核" -#: authentication/errors.py:55 +#: authentication/errors.py:56 msgid "Wait login confirm ticket for accept" msgstr "等待登录复核处理" -#: authentication/errors.py:56 +#: authentication/errors.py:57 msgid "Login confirm ticket was {}" msgstr "登录复核 {}" -#: authentication/forms.py:29 users/forms/user.py:199 +#: authentication/errors.py:213 +msgid "SSO auth closed" +msgstr "SSO 认证关闭了" + +#: authentication/errors.py:218 authentication/views/login.py:237 +msgid "Your password is too simple, please change it for security" +msgstr "你的密码过于简单,为了安全,请修改" + +#: authentication/forms.py:26 authentication/forms.py:34 +#: authentication/templates/authentication/login.html:38 +#: authentication/templates/authentication/xpack_login.html:118 +#: users/forms/user.py:199 msgid "MFA code" msgstr "多因子认证验证码" -#: authentication/models.py:19 +#: authentication/models.py:22 #: authentication/templates/authentication/_access_key_modal.html:32 #: perms/models/base.py:51 users/templates/users/_select_user_modal.html:18 #: users/templates/users/user_detail.html:132 @@ -1181,23 +1210,27 @@ msgstr "多因子认证验证码" msgid "Active" msgstr "激活中" -#: authentication/models.py:39 +#: authentication/models.py:42 msgid "Private Token" msgstr "SSH密钥" -#: authentication/models.py:44 users/templates/users/user_detail.html:258 +#: authentication/models.py:47 users/templates/users/user_detail.html:258 msgid "Reviewers" msgstr "审批人" -#: authentication/models.py:53 tickets/models/ticket.py:26 +#: authentication/models.py:56 tickets/models/ticket.py:23 #: users/templates/users/user_detail.html:250 msgid "Login confirm" msgstr "登录复核" -#: authentication/models.py:63 +#: authentication/models.py:66 msgid "City" msgstr "城市" +#: authentication/models.py:89 +msgid "Expired" +msgstr "过期时间" + #: authentication/templates/authentication/_access_key_modal.html:6 msgid "API key list" msgstr "API Key列表" @@ -1224,7 +1257,7 @@ msgid "Show" msgstr "显示" #: authentication/templates/authentication/_access_key_modal.html:66 -#: users/models/user.py:387 users/serializers/user.py:213 +#: users/models/user.py:409 users/serializers/user.py:237 #: users/templates/users/user_profile.html:94 #: users/templates/users/user_profile.html:163 #: users/templates/users/user_profile.html:166 @@ -1233,7 +1266,7 @@ msgid "Disable" msgstr "禁用" #: authentication/templates/authentication/_access_key_modal.html:67 -#: users/models/user.py:388 users/serializers/user.py:214 +#: users/models/user.py:410 users/serializers/user.py:238 #: users/templates/users/user_profile.html:92 #: users/templates/users/user_profile.html:170 msgid "Enable" @@ -1245,7 +1278,7 @@ msgstr "删除成功" #: authentication/templates/authentication/_access_key_modal.html:155 #: authentication/templates/authentication/_mfa_confirm_modal.html:53 -#: templates/_modal.html:22 tickets/models/ticket.py:70 +#: templates/_modal.html:22 tickets/models/ticket.py:67 msgid "Close" msgstr "关闭" @@ -1274,29 +1307,29 @@ msgid "Code error" msgstr "代码错误" #: authentication/templates/authentication/login.html:6 -#: authentication/templates/authentication/login.html:39 -#: authentication/templates/authentication/xpack_login.html:112 +#: authentication/templates/authentication/login.html:49 +#: authentication/templates/authentication/xpack_login.html:130 #: templates/_base_only_msg_content.html:51 templates/_header_bar.html:83 msgid "Login" msgstr "登录" #: authentication/templates/authentication/login.html:17 -#: authentication/templates/authentication/xpack_login.html:87 +#: authentication/templates/authentication/xpack_login.html:95 msgid "Captcha invalid" msgstr "验证码错误" -#: authentication/templates/authentication/login.html:50 -#: authentication/templates/authentication/xpack_login.html:116 +#: authentication/templates/authentication/login.html:60 +#: authentication/templates/authentication/xpack_login.html:134 #: users/templates/users/forgot_password.html:7 #: users/templates/users/forgot_password.html:8 msgid "Forgot password" msgstr "忘记密码" -#: authentication/templates/authentication/login.html:57 +#: authentication/templates/authentication/login.html:67 msgid "More login options" msgstr "更多登录方式" -#: authentication/templates/authentication/login.html:61 +#: authentication/templates/authentication/login.html:71 msgid "OpenID" msgstr "OpenID" @@ -1337,7 +1370,7 @@ msgstr "返回" msgid "Copy success" msgstr "复制成功" -#: authentication/templates/authentication/xpack_login.html:74 +#: authentication/templates/authentication/xpack_login.html:78 msgid "Welcome back, please enter username and password to login" msgstr "欢迎回来,请输入用户名和密码登录" @@ -1345,7 +1378,7 @@ msgstr "欢迎回来,请输入用户名和密码登录" msgid "Please enable cookies and try again." msgstr "设置你的浏览器支持cookie" -#: authentication/views/login.py:172 +#: authentication/views/login.py:183 msgid "" "Wait for {} confirm, You also can copy link to her/him
\n" " Don't close this page" @@ -1353,18 +1386,22 @@ msgstr "" "等待 {} 确认, 你也可以复制链接发给他/她
\n" " 不要关闭本页面" -#: authentication/views/login.py:177 +#: authentication/views/login.py:188 msgid "No ticket found" msgstr "没有发现工单" -#: authentication/views/login.py:209 +#: authentication/views/login.py:220 msgid "Logout success" msgstr "退出登录成功" -#: authentication/views/login.py:210 +#: authentication/views/login.py:221 msgid "Logout success, return login page" msgstr "退出登录成功,返回到登录页面" +#: authentication/views/login.py:236 +msgid "Please change your password" +msgstr "请修改密码" + #: common/const/__init__.py:6 #, python-format msgid "%(name)s was created successfully" @@ -1375,11 +1412,20 @@ msgstr "%(name)s 创建成功" msgid "%(name)s was updated successfully" msgstr "%(name)s 更新成功" +#: common/db/models.py:68 +msgid "Updated by" +msgstr "更新人" + #: common/drf/parsers/csv.py:22 #, python-format msgid "The max size of CSV is %d bytes" msgstr "CSV 文件最大为 %d 字节" +#: common/exceptions.py:15 +#, python-format +msgid "%s object does not exist." +msgstr "%s对象不存在" + #: common/fields/form.py:33 msgid "Not a valid json" msgstr "不是合法json" @@ -1602,11 +1648,11 @@ msgstr "命令 `{}` 不允许被执行 ......." msgid "Task end" msgstr "任务结束" -#: ops/tasks.py:68 +#: ops/tasks.py:71 msgid "Clean task history period" msgstr "定期清除任务历史" -#: ops/tasks.py:81 +#: ops/tasks.py:84 msgid "Clean celery log period" msgstr "定期清除Celery日志" @@ -1622,18 +1668,35 @@ msgstr "更新任务内容: {}" msgid "Disk used more than 80%: {} => {}" msgstr "磁盘使用率超过 80%: {} => {}" -#: orgs/api.py:57 +#: orgs/api.py:58 msgid "Organization contains undeleted resources" -msgstr "组织内包含未删除的资源" +msgstr "" -#: orgs/api.py:61 +#: orgs/api.py:62 msgid "The current organization cannot be deleted" -msgstr "当能删除当前所在组织" +msgstr "" -#: orgs/mixins/models.py:56 orgs/mixins/serializers.py:26 orgs/models.py:31 +#: orgs/mixins/models.py:56 orgs/mixins/serializers.py:25 orgs/models.py:40 +#: orgs/models.py:321 msgid "Organization" msgstr "组织" +#: orgs/models.py:15 +msgid "Organization administrator" +msgstr "组织管理员" + +#: orgs/models.py:17 +msgid "Organization auditor" +msgstr "组织审计员" + +#: orgs/models.py:323 users/forms/user.py:27 users/models/user.py:499 +#: users/templates/users/_select_user_modal.html:15 +#: users/templates/users/user_detail.html:73 +#: users/templates/users/user_list.html:16 +#: users/templates/users/user_profile.html:55 +msgid "Role" +msgstr "角色" + #: perms/const.py:7 msgid "Ungrouped" msgstr "未分组" @@ -1651,7 +1714,8 @@ msgstr "提示:RDP 协议不支持单独控制上传或下载文件" #: perms/forms/asset_permission.py:86 perms/forms/database_app_permission.py:41 #: perms/forms/remote_app_permission.py:43 perms/models/base.py:50 #: templates/_nav.html:21 users/forms/user.py:168 users/models/group.py:31 -#: users/models/user.py:473 users/templates/users/_select_user_modal.html:16 +#: users/models/user.py:495 users/serializers/user.py:49 +#: users/templates/users/_select_user_modal.html:16 #: users/templates/users/user_asset_permission.html:39 #: users/templates/users/user_asset_permission.html:67 #: users/templates/users/user_database_app_permission.html:38 @@ -1675,37 +1739,49 @@ msgstr "资产和节点至少选一个" msgid "System users" msgstr "系统用户" -#: perms/models/asset_permission.py:31 settings/serializers/settings.py:56 +#: perms/models/asset_permission.py:35 settings/serializers/settings.py:56 msgid "All" msgstr "全部" -#: perms/models/asset_permission.py:32 +#: perms/models/asset_permission.py:36 msgid "Connect" msgstr "连接" -#: perms/models/asset_permission.py:33 +#: perms/models/asset_permission.py:37 msgid "Upload file" msgstr "上传文件" -#: perms/models/asset_permission.py:34 +#: perms/models/asset_permission.py:38 msgid "Download file" msgstr "下载文件" -#: perms/models/asset_permission.py:35 +#: perms/models/asset_permission.py:39 msgid "Upload download" msgstr "上传下载" -#: perms/models/asset_permission.py:83 +#: perms/models/asset_permission.py:40 +msgid "Clipboard copy" +msgstr "" + +#: perms/models/asset_permission.py:41 +msgid "Clipboard paste" +msgstr "" + +#: perms/models/asset_permission.py:42 +msgid "Clipboard copy paste" +msgstr "" + +#: perms/models/asset_permission.py:93 msgid "Actions" msgstr "动作" -#: perms/models/asset_permission.py:87 templates/_nav.html:78 +#: perms/models/asset_permission.py:97 templates/_nav.html:78 #: users/templates/users/_user_detail_nav_header.html:31 msgid "Asset permission" msgstr "资产授权" -#: perms/models/base.py:53 tickets/serializers/request_asset_perm.py:20 -#: users/models/user.py:505 users/templates/users/user_detail.html:93 +#: perms/models/base.py:53 tickets/serializers/request_asset_perm.py:31 +#: users/models/user.py:527 users/templates/users/user_detail.html:93 #: users/templates/users/user_profile.html:120 msgid "Date expired" msgstr "失效日期" @@ -1715,6 +1791,10 @@ msgstr "失效日期" msgid "DatabaseApp permission" msgstr "数据库应用授权" +#: perms/models/k8s_app_permission.py:27 +msgid "KubernetesApp permission" +msgstr "Kubernetes应用授权" + #: perms/models/remote_app_permission.py:20 #: users/templates/users/_user_detail_nav_header.html:47 msgid "RemoteApp permission" @@ -2385,6 +2465,10 @@ msgstr "会话" msgid "Risk level" msgstr "风险等级" +#: terminal/exceptions.py:8 +msgid "Bulk create not support" +msgstr "不支持批量创建" + #: terminal/models.py:27 msgid "Remote Address" msgstr "远端地址" @@ -2445,44 +2529,52 @@ msgstr "结束日期" msgid "Args" msgstr "参数" -#: tickets/api/request_asset_perm.py:41 -msgid "Ticket closed" -msgstr "工单已关闭" - -#: tickets/api/request_asset_perm.py:44 +#: tickets/api/request_asset_perm.py:45 #, python-format msgid "Ticket has %s" msgstr "工单已%s" -#: tickets/api/request_asset_perm.py:69 -msgid "Superuser" -msgstr "超级管理员" - -#: tickets/api/request_asset_perm.py:99 +#: tickets/api/request_asset_perm.py:90 msgid "Confirm assets first" msgstr "请先确认资产" -#: tickets/api/request_asset_perm.py:102 +#: tickets/api/request_asset_perm.py:93 msgid "Confirmed assets changed" msgstr "确认的资产变更了" -#: tickets/api/request_asset_perm.py:106 +#: tickets/api/request_asset_perm.py:97 msgid "Confirm system-user first" msgstr "请先确认系统用户" -#: tickets/api/request_asset_perm.py:110 +#: tickets/api/request_asset_perm.py:101 msgid "Confirmed system-user changed" msgstr "确认的系统用户变更了" -#: tickets/api/request_asset_perm.py:113 xpack/plugins/cloud/models.py:202 +#: tickets/api/request_asset_perm.py:104 xpack/plugins/cloud/models.py:202 msgid "Succeed" msgstr "成功" -#: tickets/api/request_asset_perm.py:121 +#: tickets/api/request_asset_perm.py:112 +msgid "From request ticket: {} {}" +msgstr "来自工单申请: {} {}" + +#: tickets/api/request_asset_perm.py:114 msgid "{} request assets, approved by {}" msgstr "{} 申请资产,通过人 {}" -#: tickets/models/ticket.py:18 tickets/models/ticket.py:72 +#: tickets/exceptions.py:23 +msgid "Ticket closed" +msgstr "工单已关闭" + +#: tickets/exceptions.py:32 +msgid "Only assignee can operate ticket" +msgstr "只有审批人可以操作工单" + +#: tickets/exceptions.py:37 +msgid "Ticket can not be operated" +msgstr "不能操作该工单" + +#: tickets/models/ticket.py:18 tickets/models/ticket.py:69 msgid "Open" msgstr "开启" @@ -2490,79 +2582,106 @@ msgstr "开启" msgid "Closed" msgstr "关闭" -#: tickets/models/ticket.py:25 +#: tickets/models/ticket.py:22 msgid "General" msgstr "一般" -#: tickets/models/ticket.py:27 +#: tickets/models/ticket.py:24 msgid "Request asset permission" msgstr "申请资产权限" -#: tickets/models/ticket.py:32 +#: tickets/models/ticket.py:27 msgid "Approve" msgstr "同意" -#: tickets/models/ticket.py:33 +#: tickets/models/ticket.py:28 msgid "Reject" msgstr "拒绝" -#: tickets/models/ticket.py:36 tickets/models/ticket.py:131 +#: tickets/models/ticket.py:31 tickets/models/ticket.py:138 msgid "User display name" msgstr "用户显示名称" -#: tickets/models/ticket.py:38 +#: tickets/models/ticket.py:33 msgid "Title" msgstr "标题" -#: tickets/models/ticket.py:39 tickets/models/ticket.py:132 +#: tickets/models/ticket.py:34 tickets/models/ticket.py:139 msgid "Body" msgstr "内容" -#: tickets/models/ticket.py:41 +#: tickets/models/ticket.py:36 msgid "Assignee" msgstr "处理人" -#: tickets/models/ticket.py:42 +#: tickets/models/ticket.py:37 msgid "Assignee display name" msgstr "处理人名称" -#: tickets/models/ticket.py:43 +#: tickets/models/ticket.py:38 msgid "Assignees" msgstr "待处理人" -#: tickets/models/ticket.py:44 +#: tickets/models/ticket.py:39 msgid "Assignees display name" msgstr "待处理人名称" -#: tickets/models/ticket.py:73 +#: tickets/models/ticket.py:70 msgid "{} {} this ticket" msgstr "{} {} 这个工单" -#: tickets/models/ticket.py:84 +#: tickets/models/ticket.py:85 msgid "this ticket" msgstr "这个工单" -#: tickets/serializers/request_asset_perm.py:12 +#: tickets/serializers/request_asset_perm.py:23 msgid "IP group" msgstr "IP组" -#: tickets/serializers/request_asset_perm.py:24 +#: tickets/serializers/request_asset_perm.py:35 msgid "Confirmed assets" msgstr "确认的资产" -#: tickets/serializers/request_asset_perm.py:28 +#: tickets/serializers/request_asset_perm.py:38 msgid "Confirmed system user" msgstr "确认的系统用户" -#: tickets/serializers/request_asset_perm.py:65 -msgid "Must be organization admin or superuser" -msgstr "必须是组织管理员或者超级管理员" +#: tickets/serializers/request_asset_perm.py:87 +msgid "Invalid `org_id`" +msgstr "无效的 `org_id`" -#: tickets/utils.py:18 +#: tickets/serializers/request_asset_perm.py:96 +msgid "Field `assignees` must be organization admin or superuser" +msgstr "字段 assignees 必须是组织管理员或者超级管理员" + +#: tickets/serializers/request_asset_perm.py:146 +#, python-brace-format +msgid "" +"\n" +" Type: {type}
\n" +" User: {username}
\n" +" Ip group: {ips}
\n" +" Hostname: {hostname}
\n" +" System user: {system_user}
\n" +" Date start: {date_start}
\n" +" Date expired: {date_expired}
\n" +" " +msgstr "" +"\n" +" 类型: {type}
\n" +" 用户: {username}
\n" +" IP 组: {ips}
\n" +" 主机名: {hostname}
\n" +" 系统用户: {system_user}
\n" +" 开始时间: {date_start}
\n" +" 过期时间: {date_expired}
\n" +" " + +#: tickets/utils.py:20 msgid "New ticket" msgstr "新工单" -#: tickets/utils.py:21 +#: tickets/utils.py:28 #, python-brace-format msgid "" "\n" @@ -2587,11 +2706,11 @@ msgstr "" "
\n" " " -#: tickets/utils.py:40 +#: tickets/utils.py:47 msgid "Ticket has been reply" msgstr "工单已被回复" -#: tickets/utils.py:41 +#: tickets/utils.py:48 #, python-brace-format msgid "" "\n" @@ -2622,7 +2741,7 @@ msgstr "" "
\n" " " -#: users/api/user.py:119 +#: users/api/user.py:147 msgid "Could not reset self otp, use profile reset instead" msgstr "不能在该页面重置多因子认证, 请去个人信息页面重置" @@ -2668,7 +2787,7 @@ msgstr "确认密码" msgid "Password does not match" msgstr "密码不一致" -#: users/forms/profile.py:89 users/models/user.py:469 +#: users/forms/profile.py:89 users/models/user.py:491 #: users/templates/users/user_detail.html:57 #: users/templates/users/user_profile.html:59 msgid "Email" @@ -2704,20 +2823,12 @@ msgid "Public key should not be the same as your old one." msgstr "不能和原来的密钥相同" #: users/forms/profile.py:137 users/forms/user.py:90 -#: users/serializers/user.py:177 users/serializers/user.py:258 -#: users/serializers/user.py:316 +#: users/serializers/user.py:200 users/serializers/user.py:282 +#: users/serializers/user.py:340 msgid "Not a valid ssh public key" msgstr "SSH密钥不合法" -#: users/forms/user.py:27 users/models/user.py:477 -#: users/templates/users/_select_user_modal.html:15 -#: users/templates/users/user_detail.html:73 -#: users/templates/users/user_list.html:16 -#: users/templates/users/user_profile.html:55 -msgid "Role" -msgstr "角色" - -#: users/forms/user.py:31 users/models/user.py:512 +#: users/forms/user.py:31 users/models/user.py:534 #: users/templates/users/user_detail.html:89 #: users/templates/users/user_list.html:18 #: users/templates/users/user_profile.html:102 @@ -2737,105 +2848,109 @@ msgstr "添加到用户组" msgid "* Your password does not meet the requirements" msgstr "* 您的密码不符合要求" -#: users/forms/user.py:124 users/serializers/user.py:30 +#: users/forms/user.py:124 users/serializers/user.py:37 msgid "Reset link will be generated and sent to the user" msgstr "生成重置密码链接,通过邮件发送给用户" -#: users/forms/user.py:125 users/serializers/user.py:31 +#: users/forms/user.py:125 users/serializers/user.py:38 msgid "Set password" msgstr "设置密码" -#: users/forms/user.py:132 users/serializers/user.py:38 +#: users/forms/user.py:132 users/serializers/user.py:45 #: xpack/plugins/change_auth_plan/models.py:61 #: xpack/plugins/change_auth_plan/serializers.py:30 msgid "Password strategy" msgstr "密码策略" -#: users/models/user.py:159 users/models/user.py:623 -msgid "Administrator" -msgstr "管理员" +#: users/models/user.py:156 +msgid "Super administrator" +msgstr "超级管理员" -#: users/models/user.py:161 +#: users/models/user.py:158 +msgid "Super auditor" +msgstr "超级审计员" + +#: users/models/user.py:159 msgid "Application" msgstr "应用程序" -#: users/models/user.py:162 -msgid "Auditor" -msgstr "审计员" - -#: users/models/user.py:172 -msgid "Org admin" -msgstr "组织管理员" - -#: users/models/user.py:174 -msgid "Org auditor" -msgstr "组织审计员" - -#: users/models/user.py:389 users/templates/users/user_profile.html:90 +#: users/models/user.py:411 users/templates/users/user_profile.html:90 msgid "Force enable" msgstr "强制启用" -#: users/models/user.py:456 +#: users/models/user.py:478 msgid "Local" msgstr "数据库" -#: users/models/user.py:480 +#: users/models/user.py:502 msgid "Avatar" msgstr "头像" -#: users/models/user.py:483 users/templates/users/user_detail.html:68 +#: users/models/user.py:505 users/templates/users/user_detail.html:68 msgid "Wechat" msgstr "微信" -#: users/models/user.py:516 +#: users/models/user.py:538 msgid "Date password last updated" msgstr "最后更新密码日期" -#: users/models/user.py:626 +#: users/models/user.py:651 +msgid "Administrator" +msgstr "管理员" + +#: users/models/user.py:654 msgid "Administrator is the super user of system" msgstr "Administrator是初始的超级管理员" -#: users/serializers/user.py:69 users/serializers/user.py:229 +#: users/serializers/user.py:55 users/serializers/user.py:93 +msgid "Organization role name" +msgstr "组织角色名称" + +#: users/serializers/user.py:59 +msgid "Total role name" +msgstr "汇总角色名称" + +#: users/serializers/user.py:84 users/serializers/user.py:253 msgid "Is first login" msgstr "首次登录" -#: users/serializers/user.py:70 +#: users/serializers/user.py:85 msgid "Is valid" msgstr "账户是否有效" -#: users/serializers/user.py:71 +#: users/serializers/user.py:86 msgid "Is expired" msgstr " 是否过期" -#: users/serializers/user.py:72 +#: users/serializers/user.py:87 msgid "Avatar url" msgstr "头像路径" -#: users/serializers/user.py:76 +#: users/serializers/user.py:91 msgid "Groups name" msgstr "用户组名" -#: users/serializers/user.py:77 +#: users/serializers/user.py:92 msgid "Source name" msgstr "用户来源名" -#: users/serializers/user.py:78 -msgid "Role name" -msgstr "角色名" +#: users/serializers/user.py:94 +msgid "Super role name" +msgstr "超级角色名称" -#: users/serializers/user.py:97 +#: users/serializers/user.py:120 msgid "Role limit to {}" msgstr "角色只能为 {}" -#: users/serializers/user.py:109 users/serializers/user.py:282 +#: users/serializers/user.py:132 users/serializers/user.py:306 msgid "Password does not match security rules" msgstr "密码不满足安全规则" -#: users/serializers/user.py:274 +#: users/serializers/user.py:298 msgid "The old password is incorrect" msgstr "旧密码错误" -#: users/serializers/user.py:288 +#: users/serializers/user.py:312 msgid "The newly set password is inconsistent" msgstr "两次密码不一致" @@ -2849,7 +2964,7 @@ msgstr "安全令牌验证" #: users/templates/users/_base_otp.html:14 users/templates/users/_user.html:13 #: users/templates/users/user_profile_update.html:55 -#: xpack/plugins/cloud/models.py:119 xpack/plugins/cloud/serializers.py:81 +#: xpack/plugins/cloud/models.py:119 xpack/plugins/cloud/serializers.py:82 msgid "Account" msgstr "账户" @@ -3736,7 +3851,7 @@ msgstr "" msgid "Cloud account" msgstr "云账号" -#: xpack/plugins/cloud/models.py:122 xpack/plugins/cloud/serializers.py:58 +#: xpack/plugins/cloud/models.py:122 xpack/plugins/cloud/serializers.py:59 msgid "Regions" msgstr "地域" @@ -3744,7 +3859,7 @@ msgstr "地域" msgid "Instances" msgstr "实例" -#: xpack/plugins/cloud/models.py:137 xpack/plugins/cloud/serializers.py:85 +#: xpack/plugins/cloud/models.py:137 xpack/plugins/cloud/serializers.py:86 msgid "Always update" msgstr "总是更新" @@ -3860,15 +3975,15 @@ msgstr "拉美-圣地亚哥" msgid "Tencent Cloud" msgstr "腾讯云" -#: xpack/plugins/cloud/serializers.py:56 +#: xpack/plugins/cloud/serializers.py:57 msgid "History count" msgstr "执行次数" -#: xpack/plugins/cloud/serializers.py:57 +#: xpack/plugins/cloud/serializers.py:58 msgid "Instance count" msgstr "实例个数" -#: xpack/plugins/cloud/serializers.py:84 +#: xpack/plugins/cloud/serializers.py:85 #: xpack/plugins/gathered_user/serializers.py:20 msgid "Periodic display" msgstr "定时执行" @@ -3957,12 +4072,24 @@ msgstr "企业版" msgid "Ultimate edition" msgstr "旗舰版" +#~ msgid "Auditor" +#~ msgstr "审计员" + +#~ msgid "Org admin" +#~ msgstr "组织管理员" + +#~ msgid "Role name" +#~ msgstr "角色名" + #~ msgid "Covered always" #~ msgstr "总是被覆盖" #~ msgid "Account name" #~ msgstr "账户名称" +#~ msgid "Superuser" +#~ msgstr "超级管理员" + #~ msgid "Auditors cannot be join in the user group" #~ msgstr "审计员不能被加入到用户组" @@ -4122,9 +4249,6 @@ msgstr "旗舰版" #~ msgid "Update asset user auth" #~ msgstr "更新资产用户认证信息" -#~ msgid "Please input password" -#~ msgstr "请输入密码" - #~ msgid "Asset user auth" #~ msgstr "资产用户信息" @@ -5472,9 +5596,6 @@ msgstr "旗舰版" #~ msgid "Corporation" #~ msgstr "公司" -#~ msgid "Expired" -#~ msgstr "过期时间" - #~ msgid "Edition" #~ msgstr "版本" @@ -5490,9 +5611,6 @@ msgstr "旗舰版" #~ msgid "Admin" #~ msgstr "管理员" -#~ msgid "Organizations" -#~ msgstr "组织管理" - #~ msgid "Org detail" #~ msgstr "组织详情" diff --git a/apps/ops/celery/utils.py b/apps/ops/celery/utils.py index 55ce4a9d1..0c758b70e 100644 --- a/apps/ops/celery/utils.py +++ b/apps/ops/celery/utils.py @@ -93,6 +93,12 @@ def delete_celery_periodic_task(task_name): PeriodicTasks.update_changed() +def get_celery_periodic_task(task_name): + from django_celery_beat.models import PeriodicTask + task = PeriodicTask.objects.filter(name=task_name).first() + return task + + def get_celery_task_log_path(task_id): task_id = str(task_id) rel_path = os.path.join(task_id[0], task_id[1], task_id + '.log') diff --git a/apps/ops/tasks.py b/apps/ops/tasks.py index 7ea11a3e4..3419c8976 100644 --- a/apps/ops/tasks.py +++ b/apps/ops/tasks.py @@ -15,7 +15,10 @@ from .celery.decorator import ( register_as_period_task, after_app_shutdown_clean_periodic, after_app_ready_start ) -from .celery.utils import create_or_update_celery_periodic_tasks +from .celery.utils import ( + create_or_update_celery_periodic_tasks, get_celery_periodic_task, + disable_celery_periodic_task, delete_celery_periodic_task +) from .models import Task, CommandExecution, CeleryTask from .utils import send_server_performance_mail @@ -95,6 +98,29 @@ def clean_celery_tasks_period(): subprocess.call(command, shell=True) +@shared_task +@after_app_ready_start +def clean_celery_periodic_tasks(): + """清除celery定时任务""" + need_cleaned_tasks = [ + 'handle_be_interrupted_change_auth_task_periodic', + ] + logger.info('Start clean celery periodic tasks: {}'.format(need_cleaned_tasks)) + for task_name in need_cleaned_tasks: + logger.info('Start clean task: {}'.format(task_name)) + task = get_celery_periodic_task(task_name) + if task is None: + logger.info('Task does not exist: {}'.format(task_name)) + continue + disable_celery_periodic_task(task_name) + delete_celery_periodic_task(task_name) + task = get_celery_periodic_task(task_name) + if task is None: + logger.info('Clean task success: {}'.format(task_name)) + else: + logger.info('Clean task failure: {}'.format(task)) + + @shared_task @after_app_ready_start def create_or_update_registered_periodic_tasks(): diff --git a/apps/ops/utils.py b/apps/ops/utils.py index 81b5a1afd..83501a6cb 100644 --- a/apps/ops/utils.py +++ b/apps/ops/utils.py @@ -69,7 +69,7 @@ def send_server_performance_mail(path, usage, usages): from users.models import User subject = _("Disk used more than 80%: {} => {}").format(path, usage.percent) message = subject - admins = User.objects.filter(role=User.ROLE_ADMIN) + admins = User.objects.filter(role=User.ROLE.ADMIN) recipient_list = [u.email for u in admins if u.email] logger.info(subject) send_mail_async(subject, message, recipient_list, html_message=message) diff --git a/apps/orgs/api.py b/apps/orgs/api.py index d8ba32635..d283019d3 100644 --- a/apps/orgs/api.py +++ b/apps/orgs/api.py @@ -1,23 +1,24 @@ # -*- coding: utf-8 -*- # -from django.shortcuts import get_object_or_404 from django.utils.translation import ugettext as _ from rest_framework import status, generics from rest_framework.views import Response from rest_framework_bulk import BulkModelViewSet from common.permissions import IsSuperUserOrAppUser -from .models import Organization -from .serializers import OrgSerializer, OrgReadSerializer, \ - OrgMembershipUserSerializer, OrgMembershipAdminSerializer, \ - OrgAllUserSerializer, OrgRetrieveSerializer +from common.drf.api import JMSBulkRelationModelViewSet +from .models import Organization, ROLE +from .serializers import ( + OrgSerializer, OrgReadSerializer, + OrgRetrieveSerializer, OrgMemberSerializer +) from users.models import User, UserGroup from assets.models import Asset, Domain, AdminUser, SystemUser, Label from perms.models import AssetPermission from orgs.utils import current_org from common.utils import get_logger -from .mixins.api import OrgMembershipModelViewSetMixin +from .filters import OrgMemberRelationFilterSet logger = get_logger(__file__) @@ -39,7 +40,7 @@ class OrgViewSet(BulkModelViewSet): def get_data_from_model(self, model): if model == User: - data = model.objects.filter(related_user_orgs__id=self.org.id) + data = model.objects.filter(orgs__id=self.org.id, m2m_org_members__role=ROLE.USER) else: data = model.objects.filter(org_id=self.org.id) return data @@ -64,26 +65,13 @@ class OrgViewSet(BulkModelViewSet): return Response({'msg': True}, status=status.HTTP_200_OK) -class OrgMembershipAdminsViewSet(OrgMembershipModelViewSetMixin, BulkModelViewSet): - serializer_class = OrgMembershipAdminSerializer - membership_class = Organization.admins.through - permission_classes = (IsSuperUserOrAppUser, ) - - -class OrgMembershipUsersViewSet(OrgMembershipModelViewSetMixin, BulkModelViewSet): - serializer_class = OrgMembershipUserSerializer - membership_class = Organization.users.through - permission_classes = (IsSuperUserOrAppUser, ) - - -class OrgAllUserListApi(generics.ListAPIView): +class OrgMemberRelationBulkViewSet(JMSBulkRelationModelViewSet): permission_classes = (IsSuperUserOrAppUser,) - serializer_class = OrgAllUserSerializer - filter_fields = ("username", "name") - search_fields = filter_fields + m2m_field = Organization.members.field + serializer_class = OrgMemberSerializer + filterset_class = OrgMemberRelationFilterSet - def get_queryset(self): - pk = self.kwargs.get("pk") - org = get_object_or_404(Organization, pk=pk) - users = org.get_org_users().only(*self.serializer_class.Meta.only_fields) - return users + def perform_bulk_destroy(self, queryset): + objs = list(queryset.all().prefetch_related('user', 'org')) + queryset.delete() + self.send_m2m_changed_signal(objs, action='post_remove') diff --git a/apps/orgs/filters.py b/apps/orgs/filters.py new file mode 100644 index 000000000..df68e468f --- /dev/null +++ b/apps/orgs/filters.py @@ -0,0 +1,16 @@ +from django_filters.rest_framework import filterset +from django_filters.rest_framework import filters + +from .models import OrganizationMember + + +class UUIDInFilter(filters.BaseInFilter, filters.UUIDFilter): + pass + + +class OrgMemberRelationFilterSet(filterset.FilterSet): + id = UUIDInFilter(field_name='id', lookup_expr='in') + + class Meta: + model = OrganizationMember + fields = ('org_id', 'user_id', 'role', 'id') diff --git a/apps/orgs/migrations/0004_organizationmember.py b/apps/orgs/migrations/0004_organizationmember.py new file mode 100644 index 000000000..6b43a5850 --- /dev/null +++ b/apps/orgs/migrations/0004_organizationmember.py @@ -0,0 +1,33 @@ +# Generated by Django 2.2.10 on 2020-07-21 11:27 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('orgs', '0003_auto_20190916_1057'), + ] + + operations = [ + migrations.CreateModel( + name='OrganizationMember', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('role', models.CharField(choices=[('Admin', 'Administrator'), ('User', 'User'), ('Auditor', 'Auditor')], default='User', max_length=16, verbose_name='Role')), + ('date_created', models.DateTimeField(auto_now_add=True, verbose_name='Date created')), + ('date_updated', models.DateTimeField(auto_now=True, verbose_name='Date updated')), + ('created_by', models.CharField(max_length=128, null=True, verbose_name='Created by')), + ('org', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='m2m_org_members', to='orgs.Organization', verbose_name='Organization')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='m2m_org_members', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'db_table': 'orgs_organization_members', + 'unique_together': {('org', 'user', 'role')}, + }, + ), + ] diff --git a/apps/orgs/migrations/0005_auto_20200721_1937.py b/apps/orgs/migrations/0005_auto_20200721_1937.py new file mode 100644 index 000000000..ac0ec8a08 --- /dev/null +++ b/apps/orgs/migrations/0005_auto_20200721_1937.py @@ -0,0 +1,35 @@ +# Generated by Django 2.2.10 on 2020-07-21 11:37 + +from django.db import migrations + + +def migrate_old_organization_members(apps, schema_editor): + org_model = apps.get_model("orgs", "Organization") + org_member_model = apps.get_model('orgs', 'OrganizationMember') + orgs = org_model.objects.all() + + roles = ['User', 'Auditor', 'Admin'] + + for org in orgs: + users = org.users.all().only('id') + auditors = org.auditors.all().only('id') + admins = org.admins.all().only('id') + total_members = zip([users, auditors, admins], roles) + + org_members = [] + for members, role in total_members: + for user in members: + org_user = org_member_model(user=user, org=org, role=role) + org_members.append(org_user) + org_member_model.objects.bulk_create(org_members) + + +class Migration(migrations.Migration): + + dependencies = [ + ('orgs', '0004_organizationmember'), + ] + + operations = [ + migrations.RunPython(migrate_old_organization_members) + ] diff --git a/apps/orgs/migrations/0006_auto_20200721_1937.py b/apps/orgs/migrations/0006_auto_20200721_1937.py new file mode 100644 index 000000000..fe0b1f477 --- /dev/null +++ b/apps/orgs/migrations/0006_auto_20200721_1937.py @@ -0,0 +1,32 @@ +# Generated by Django 2.2.10 on 2020-07-21 11:37 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('orgs', '0005_auto_20200721_1937'), + ] + + operations = [ + migrations.RemoveField( + model_name='organization', + name='admins', + ), + migrations.RemoveField( + model_name='organization', + name='auditors', + ), + migrations.RemoveField( + model_name='organization', + name='users', + ), + migrations.AddField( + model_name='organization', + name='members', + field=models.ManyToManyField(related_name='orgs', through='orgs.OrganizationMember', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/apps/orgs/migrations/0007_auto_20200728_1805.py b/apps/orgs/migrations/0007_auto_20200728_1805.py new file mode 100644 index 000000000..6c05e75b2 --- /dev/null +++ b/apps/orgs/migrations/0007_auto_20200728_1805.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.10 on 2020-07-28 10:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('orgs', '0006_auto_20200721_1937'), + ] + + operations = [ + migrations.AlterField( + model_name='organizationmember', + name='role', + field=models.CharField(choices=[('Admin', 'Organization administrator'), ('User', 'User'), ('Auditor', 'Organization auditor')], default='User', max_length=16, verbose_name='Role'), + ), + ] diff --git a/apps/orgs/mixins/api.py b/apps/orgs/mixins/api.py index 635e415bf..f40f9f7fa 100644 --- a/apps/orgs/mixins/api.py +++ b/apps/orgs/mixins/api.py @@ -55,8 +55,8 @@ class OrgBulkModelViewSet(CommonApiMixin, OrgQuerySetMixin, BulkModelViewSet): filtered_count = filtered.count() if filtered_count == 1: return True - if qs_count <= filtered_count: - return False + if qs_count > filtered_count: + return True if self.request.query_params.get('spm', ''): return True return False diff --git a/apps/orgs/mixins/models.py b/apps/orgs/mixins/models.py index 649c450cc..c6c18902d 100644 --- a/apps/orgs/mixins/models.py +++ b/apps/orgs/mixins/models.py @@ -62,7 +62,6 @@ class OrgModelMixin(models.Model): org = get_current_org() if org is None: return super().save(*args, **kwargs) - if org.is_real() or org.is_system(): self.org_id = org.id elif org.is_default(): diff --git a/apps/orgs/mixins/serializers.py b/apps/orgs/mixins/serializers.py index 2b415e31b..fed9d1713 100644 --- a/apps/orgs/mixins/serializers.py +++ b/apps/orgs/mixins/serializers.py @@ -11,8 +11,7 @@ from ..utils import get_current_org_id_for_serializer __all__ = [ "OrgResourceSerializerMixin", "BulkOrgResourceSerializerMixin", - "BulkOrgResourceModelSerializer", "OrgMembershipSerializerMixin", - "OrgResourceModelSerializerMixin", + "BulkOrgResourceModelSerializer", "OrgResourceModelSerializerMixin", ] @@ -53,9 +52,3 @@ class BulkOrgResourceSerializerMixin(BulkSerializerMixin, OrgResourceSerializerM class BulkOrgResourceModelSerializer(BulkOrgResourceSerializerMixin, serializers.ModelSerializer): pass - - -class OrgMembershipSerializerMixin: - def run_validation(self, initial_data=None): - initial_data['organization'] = str(self.context['org'].id) - return super().run_validation(initial_data) diff --git a/apps/orgs/models.py b/apps/orgs/models.py index 6e6e6dfd6..c72d1ae82 100644 --- a/apps/orgs/models.py +++ b/apps/orgs/models.py @@ -1,21 +1,30 @@ import uuid -from django.conf import settings +from functools import partial from django.db import models +from django.db.models import signals +from django.db.models import Q from django.utils.translation import ugettext_lazy as _ -from common.utils import is_uuid, lazyproperty +from common.utils import is_uuid +from common.const import choices +from common.db.models import ChoiceSet + + +class ROLE(ChoiceSet): + ADMIN = choices.ADMIN, _('Organization administrator') + USER = choices.USER, _('User') + AUDITOR = choices.AUDITOR, _("Organization auditor") class Organization(models.Model): id = models.UUIDField(default=uuid.uuid4, primary_key=True) name = models.CharField(max_length=128, unique=True, verbose_name=_("Name")) - users = models.ManyToManyField('users.User', related_name='related_user_orgs', blank=True) - admins = models.ManyToManyField('users.User', related_name='related_admin_orgs', blank=True) - auditors = models.ManyToManyField('users.User', related_name='related_audit_orgs', blank=True) created_by = models.CharField(max_length=32, null=True, blank=True, verbose_name=_('Created by')) date_created = models.DateTimeField(auto_now_add=True, null=True, blank=True, verbose_name=_('Date created')) comment = models.TextField(max_length=128, default='', blank=True, verbose_name=_('Comment')) + members = models.ManyToManyField('users.User', related_name='orgs', through='orgs.OrganizationMember', + through_fields=('org', 'user')) orgs = None CACHE_PREFIX = 'JMS_ORG_{}' @@ -72,29 +81,24 @@ class Organization(models.Model): org = cls.default() if default else None return org - # @lazyproperty - # lazyproperty 导致用户列表中角色显示出现不稳定的情况, 如果不加会导致数据库操作次数太多 - def org_users(self): + def get_org_members_by_role(self, role): from users.models import User if self.is_real(): - return self.users.all() - users = User.objects.filter(role=User.ROLE_USER) - if self.is_default() and not settings.DEFAULT_ORG_SHOW_ALL_USERS: - users = users.filter(related_user_orgs__isnull=True) + return self.members.filter(m2m_org_members__role=role) + users = User.objects.filter(role=role) return users - def get_org_users(self): - return self.org_users() + @property + def users(self): + return self.get_org_members_by_role(ROLE.USER) - # @lazyproperty - def org_admins(self): - from users.models import User - if self.is_real(): - return self.admins.all() - return User.objects.filter(role=User.ROLE_ADMIN) + @property + def admins(self): + return self.get_org_members_by_role(ROLE.ADMIN) - def get_org_admins(self): - return self.org_admins() + @property + def auditors(self): + return self.get_org_members_by_role(ROLE.AUDITOR) def org_id(self): if self.is_real(): @@ -104,87 +108,86 @@ class Organization(models.Model): else: return '' - # @lazyproperty - def org_auditors(self): + def get_members(self, exclude=()): from users.models import User if self.is_real(): - return self.auditors.all() - return User.objects.filter(role=User.ROLE_AUDITOR) + members = self.members.exclude(m2m_org_members__role__in=exclude) + else: + members = User.objects.exclude(role__in=exclude) - def get_org_auditors(self): - return self.org_auditors() - - def get_org_members(self, exclude=()): - from users.models import User - members = User.objects.none() - if 'Admin' not in exclude: - members |= self.get_org_admins() - if 'User' not in exclude: - members |= self.get_org_users() - if 'Auditor' not in exclude: - members |= self.get_org_auditors() - return members.exclude(role=User.ROLE_APP).distinct() + return members.exclude(role=User.ROLE.APP).distinct() def can_admin_by(self, user): if user.is_superuser: return True - if self.get_org_admins().filter(id=user.id): + if self.admins.filter(id=user.id).exists(): return True return False def can_audit_by(self, user): if user.is_super_auditor: return True - if self.get_org_auditors().filter(id=user.id): + if self.auditors.filter(id=user.id).exists(): return True return False def can_user_by(self, user): - if self.get_org_users().filter(id=user.id): + if self.users.filter(id=user.id).exists(): return True return False def is_real(self): return self.id not in (self.DEFAULT_NAME, self.ROOT_ID, self.SYSTEM_ID) + @classmethod + def get_user_orgs_by_role(cls, user, role): + if not isinstance(role, (tuple, list)): + role = (role, ) + + return cls.objects.filter( + m2m_org_members__role__in=role, + m2m_org_members__user_id=user.id + ).distinct() + + @classmethod + def get_user_all_orgs(cls, user): + return [ + *cls.objects.filter(members=user).distinct(), + cls.default() + ] + @classmethod def get_user_admin_orgs(cls, user): - admin_orgs = [] if user.is_anonymous: - return admin_orgs - elif user.is_superuser: - admin_orgs = list(cls.objects.all()) - admin_orgs.append(cls.default()) - elif user.is_org_admin: - admin_orgs = user.related_admin_orgs.all() - return admin_orgs + return cls.objects.none() + if user.is_superuser: + return [*cls.objects.all(), cls.default()] + return cls.get_user_orgs_by_role(user, ROLE.ADMIN) @classmethod def get_user_user_orgs(cls, user): - user_orgs = [] if user.is_anonymous: - return user_orgs - user_orgs = user.related_user_orgs.all() - return user_orgs + return cls.objects.none() + return [ + *cls.get_user_orgs_by_role(user, ROLE.USER), + cls.default() + ] @classmethod def get_user_audit_orgs(cls, user): - audit_orgs = [] if user.is_anonymous: - return audit_orgs - elif user.is_super_auditor: - audit_orgs = list(cls.objects.all()) - audit_orgs.append(cls.default()) - elif user.is_org_auditor: - audit_orgs = user.related_audit_orgs.all() - return audit_orgs + return cls.objects.none() + if user.is_super_auditor: + return [*cls.objects.all(), cls.default()] + return cls.get_user_orgs_by_role(user, ROLE.AUDITOR) @classmethod - def get_user_admin_or_audit_orgs(self, user): - admin_orgs = self.get_user_admin_orgs(user) - audit_orgs = self.get_user_audit_orgs(user) - orgs = set(admin_orgs) | set(audit_orgs) - return orgs + def get_user_admin_or_audit_orgs(cls, user): + if user.is_anonymous: + return cls.objects.none() + if user.is_superuser or user.is_super_auditor: + return [*cls.objects.all(), cls.default()] + return cls.get_user_orgs_by_role(user, (ROLE.AUDITOR, ROLE.ADMIN)) @classmethod def default(cls): @@ -211,8 +214,122 @@ class Organization(models.Model): from .utils import set_current_org set_current_org(self) - @classmethod - def all_orgs(cls): - orgs = list(cls.objects.all()) - orgs.append(cls.default()) - return orgs + +def _convert_to_uuid_set(users): + rst = set() + for user in users: + if isinstance(user, models.Model): + rst.add(user.id) + elif not isinstance(user, uuid.UUID): + rst.add(uuid.UUID(user)) + return rst + + +def _none2list(*args): + return ([] if v is None else v for v in args) + + +class OrgMemeberManager(models.Manager): + + def remove_users_by_role(self, org, users=None, admins=None, auditors=None): + if not any((users, admins, auditors)): + return + users, admins, auditors = _none2list(users, admins, auditors) + + send = partial(signals.m2m_changed.send, sender=self.model, instance=org, reverse=False, + model=Organization, pk_set=[*users, *admins, *auditors], using=self.db) + + send(action="pre_remove") + self.filter(org_id=org.id).filter( + Q(user__in=users, role=ROLE.USER) | + Q(user__in=admins, role=ROLE.ADMIN) | + Q(user__in=auditors, role=ROLE.AUDITOR) + ).delete() + send(action="post_remove") + + def add_users_by_role(self, org, users=None, admins=None, auditors=None): + if not any((users, admins, auditors)): + return + users, admins, auditors = _none2list(users, admins, auditors) + + add_mapper = ( + (users, ROLE.USER), + (admins, ROLE.ADMIN), + (auditors, ROLE.AUDITOR) + ) + + oms_add = [] + for users, role in add_mapper: + for user in users: + if isinstance(user, models.Model): + user = user.id + oms_add.append(self.model(org_id=org.id, user_id=user, role=role)) + + send = partial(signals.m2m_changed.send, sender=self.model, instance=org, reverse=False, + model=Organization, pk_set=[*users, *admins, *auditors], using=self.db) + + send(action='pre_add') + self.bulk_create(oms_add) + send(action='post_add') + + def _get_remove_add_set(self, new_users, old_users): + if new_users is None: + return None, None + new_users = _convert_to_uuid_set(new_users) + return (old_users - new_users), (new_users - old_users) + + def set_users_by_role(self, org, users=None, admins=None, auditors=None): + oms = self.filter(org_id=org.id).values_list('role', 'user_id') + + old_users, old_admins, old_auditors = set(), set(), set() + + mapper = { + ROLE.USER: old_users, + ROLE.ADMIN: old_admins, + ROLE.AUDITOR: old_auditors + } + + for role, user_id in oms: + if role in mapper: + mapper[role].add(user_id) + + users_remove, users_add = self._get_remove_add_set(users, old_users) + admins_remove, admins_add = self._get_remove_add_set(admins, old_admins) + auditors_remove, auditors_add = self._get_remove_add_set(auditors, old_auditors) + + self.remove_users_by_role( + org, + users_remove, + admins_remove, + auditors_remove + ) + + self.add_users_by_role( + org, + users_add, + admins_add, + auditors_add + ) + + +class OrganizationMember(models.Model): + """ + 注意:直接调用该 `Model.delete` `Model.objects.delete` 不会触发清理该用户的信号 + """ + + id = models.UUIDField(default=uuid.uuid4, primary_key=True) + org = models.ForeignKey(Organization, related_name='m2m_org_members', on_delete=models.CASCADE, verbose_name=_('Organization')) + user = models.ForeignKey('users.User', related_name='m2m_org_members', on_delete=models.CASCADE, verbose_name=_('User')) + role = models.CharField(max_length=16, choices=ROLE.choices, default=ROLE.USER, verbose_name=_("Role")) + date_created = models.DateTimeField(auto_now_add=True, verbose_name=_("Date created")) + date_updated = models.DateTimeField(auto_now=True, verbose_name=_("Date updated")) + created_by = models.CharField(max_length=128, null=True, verbose_name=_('Created by')) + + objects = OrgMemeberManager() + + class Meta: + unique_together = [('org', 'user', 'role')] + db_table = 'orgs_organization_members' + + def __str__(self): + return '{} is {}: {}'.format(self.user.name, self.org.name, self.role) diff --git a/apps/orgs/serializers.py b/apps/orgs/serializers.py index ae98420e0..d7e8ae2d1 100644 --- a/apps/orgs/serializers.py +++ b/apps/orgs/serializers.py @@ -1,16 +1,19 @@ - +from django.db.models import F from rest_framework.serializers import ModelSerializer from rest_framework import serializers -from users.models import UserGroup -from assets.models import Asset, Domain, AdminUser, SystemUser, Label -from perms.models import AssetPermission + +from users.models.user import User from common.serializers import AdaptedBulkListSerializer -from .utils import set_current_org, get_current_org -from .models import Organization -from .mixins.serializers import OrgMembershipSerializerMixin +from common.drf.serializers import BulkModelSerializer +from common.db.models import concated_display as display +from .models import Organization, OrganizationMember, ROLE as ORG_ROLE class OrgSerializer(ModelSerializer): + users = serializers.PrimaryKeyRelatedField(many=True, queryset=User.objects.all(), write_only=True) + admins = serializers.PrimaryKeyRelatedField(many=True, queryset=User.objects.all(), write_only=True) + auditors = serializers.PrimaryKeyRelatedField(many=True, queryset=User.objects.all(), write_only=True) + class Meta: model = Organization list_serializer_class = AdaptedBulkListSerializer @@ -21,41 +24,48 @@ class OrgSerializer(ModelSerializer): fields_m2m = ['users', 'admins', 'auditors'] fields = fields_small + fields_m2m read_only_fields = ['created_by', 'date_created'] - extra_kwargs = { - 'admins': {'write_only': True}, - 'users': {'write_only': True}, - 'auditors': {'write_only': True}, - } + + def create(self, validated_data): + members = self._pop_memebers(validated_data) + instance = Organization.objects.create(**validated_data) + OrganizationMember.objects.add_users_by_role(instance, *members) + return instance + + def _pop_memebers(self, validated_data): + return ( + validated_data.pop('users', None), + validated_data.pop('admins', None), + validated_data.pop('auditors', None) + ) + + def update(self, instance, validated_data): + members = self._pop_memebers(validated_data) + for attr, value in validated_data.items(): + setattr(instance, attr, value) + instance.save() + OrganizationMember.objects.set_users_by_role(instance, *members) + return instance class OrgReadSerializer(OrgSerializer): pass -class OrgMembershipAdminSerializer(OrgMembershipSerializerMixin, ModelSerializer): - class Meta: - model = Organization.admins.through - list_serializer_class = AdaptedBulkListSerializer - fields = '__all__' - - -class OrgMembershipUserSerializer(OrgMembershipSerializerMixin, ModelSerializer): - class Meta: - model = Organization.users.through - list_serializer_class = AdaptedBulkListSerializer - fields = '__all__' - - -class OrgAllUserSerializer(serializers.Serializer): - user = serializers.UUIDField(read_only=True, source='id') - user_display = serializers.SerializerMethodField() +class OrgMemberSerializer(BulkModelSerializer): + org_display = serializers.CharField() + user_display = serializers.CharField() + role_display = serializers.CharField(source='get_role_display') class Meta: - only_fields = ['id', 'username', 'name'] + model = OrganizationMember + fields = ('id', 'org', 'user', 'role', 'org_display', 'user_display', 'role_display') - @staticmethod - def get_user_display(obj): - return str(obj) + @classmethod + def setup_eager_loading(cls, queryset): + return queryset.annotate( + org_display=F('org__name'), + user_display=display('user__name', 'user__username') + ).distinct() class OrgRetrieveSerializer(OrgReadSerializer): diff --git a/apps/orgs/signals_handler.py b/apps/orgs/signals_handler.py index 17e3d525e..eb7df9741 100644 --- a/apps/orgs/signals_handler.py +++ b/apps/orgs/signals_handler.py @@ -5,7 +5,7 @@ from django.db.models.signals import m2m_changed from django.db.models.signals import post_save from django.dispatch import receiver -from .models import Organization +from .models import Organization, OrganizationMember from .hands import set_current_org, current_org, Node, get_current_org from perms.models import AssetPermission from users.models import UserGroup @@ -26,23 +26,31 @@ def on_org_create_or_update(sender, instance=None, created=False, **kwargs): instance.expire_cache() -@receiver(m2m_changed, sender=Organization.users.through) -def on_org_user_changed(sender, instance=None, **kwargs): - if isinstance(instance, Organization): - old_org = current_org - set_current_org(instance) - if kwargs['action'] == 'pre_remove': - users = kwargs['model'].objects.filter(pk__in=kwargs['pk_set']) - for user in users: - perms = AssetPermission.objects.filter(users=user) - user_groups = UserGroup.objects.filter(users=user) - for perm in perms: - perm.users.remove(user) - for user_group in user_groups: - user_group.users.remove(user) - set_current_org(old_org) +def _remove_users(model, users, org, reverse=False): + if not isinstance(users, (tuple, list, set)): + users = (users, ) + + m2m_model = model.users.through + if reverse: + m2m_field_name = model.users.field.m2m_reverse_field_name() + else: + m2m_field_name = model.users.field.m2m_field_name() + m2m_model.objects.filter(**{'user__in': users, f'{m2m_field_name}__org_id': org.id}).delete() -@receiver(m2m_changed, sender=Organization.admins.through) -def on_org_admin_change(sender, **kwargs): - Organization._user_admin_orgs = None +def _clear_users_from_org(org, users): + if not users: + return + + old_org = current_org + set_current_org(org) + _remove_users(AssetPermission, users, org) + _remove_users(UserGroup, users, org, reverse=True) + set_current_org(old_org) + + +@receiver(m2m_changed, sender=OrganizationMember) +def on_org_user_changed(sender, instance=None, action=None, pk_set=None, **kwargs): + if action == 'post_remove': + leaved_users = set(pk_set) - set(instance.members.values_list('id', flat=True)) + _clear_users_from_org(instance, leaved_users) diff --git a/apps/orgs/tests.py b/apps/orgs/tests.py index 7ce503c2d..1b007fe3d 100644 --- a/apps/orgs/tests.py +++ b/apps/orgs/tests.py @@ -1,3 +1,16 @@ -from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APITestCase +from rest_framework import status + +from users.models.user import User + + +class OrgTests(APITestCase): + def test_create(self): + print(User.objects.all()) + reverse('api-orgs:org-list') + + + +{"name":"a-07","admins":["138167d2-6843-4e25-b838-59657157c6c6"],"auditors":["8d4b3ec4-8339-4a2c-b33c-c2633da62c84"],"users":["ea60e8ce-876d-493b-a641-ff836258629c"]} -# Create your tests here. diff --git a/apps/orgs/urls/api_urls.py b/apps/orgs/urls/api_urls.py index 17f8c3c8a..56a135fcd 100644 --- a/apps/orgs/urls/api_urls.py +++ b/apps/orgs/urls/api_urls.py @@ -1,8 +1,9 @@ # -*- coding: utf-8 -*- # -from django.urls import re_path, path +from django.urls import re_path from rest_framework.routers import DefaultRouter +from rest_framework_bulk.routes import BulkRouter from common import api as capi from .. import api @@ -10,21 +11,13 @@ from .. import api app_name = 'orgs' router = DefaultRouter() - -# 将会删除 -router.register(r'orgs/(?P[0-9a-zA-Z\-]{36})/membership/admins', - api.OrgMembershipAdminsViewSet, 'membership-admins') -router.register(r'orgs/(?P[0-9a-zA-Z\-]{36})/membership/users', - api.OrgMembershipUsersViewSet, 'membership-users'), +bulk_router = BulkRouter() router.register(r'orgs', api.OrgViewSet, 'org') +bulk_router.register(r'org-memeber-relation', api.OrgMemberRelationBulkViewSet, 'org-memeber-relation') old_version_urlpatterns = [ re_path('(?Porg)/.*', capi.redirect_plural_name_api) ] -urlpatterns = [ - path('/users/all/', api.OrgAllUserListApi.as_view(), name='org-all-users'), -] - -urlpatterns += router.urls + old_version_urlpatterns +urlpatterns = router.urls + bulk_router.urls + old_version_urlpatterns diff --git a/apps/perms/api/__init__.py b/apps/perms/api/__init__.py index cba965d00..dc0282725 100644 --- a/apps/perms/api/__init__.py +++ b/apps/perms/api/__init__.py @@ -12,3 +12,6 @@ from .database_app_permission import * from .database_app_permission_relation import * from .user_database_app_permission import * from .system_user_permission import * +from .k8s_app_permission import * +from .k8s_app_permission_relation import * +from .user_k8s_app_permission import * diff --git a/apps/perms/api/k8s_app_permission.py b/apps/perms/api/k8s_app_permission.py new file mode 100644 index 000000000..b1111bd7b --- /dev/null +++ b/apps/perms/api/k8s_app_permission.py @@ -0,0 +1,21 @@ +# coding: utf-8 +# + +from orgs.mixins.api import OrgBulkModelViewSet + +from .. import models, serializers +from common.permissions import IsOrgAdmin + + +__all__ = ['K8sAppPermissionViewSet'] + + +class K8sAppPermissionViewSet(OrgBulkModelViewSet): + model = models.K8sAppPermission + serializer_classes = { + 'default': serializers.K8sAppPermissionSerializer, + 'display': serializers.K8sAppPermissionListSerializer + } + filter_fields = ('name',) + search_fields = filter_fields + permission_classes = (IsOrgAdmin,) diff --git a/apps/perms/api/k8s_app_permission_relation.py b/apps/perms/api/k8s_app_permission_relation.py new file mode 100644 index 000000000..443f32204 --- /dev/null +++ b/apps/perms/api/k8s_app_permission_relation.py @@ -0,0 +1,111 @@ +# coding: utf-8 +# +from rest_framework import generics +from django.db.models import F, Value +from django.db.models.functions import Concat +from django.shortcuts import get_object_or_404 + +from common.permissions import IsOrgAdmin +from .base import RelationViewSet +from .. import models, serializers + + +class K8sAppPermissionUserRelationViewSet(RelationViewSet): + serializer_class = serializers.K8sAppPermissionUserRelationSerializer + m2m_field = models.K8sAppPermission.users.field + permission_classes = (IsOrgAdmin,) + filter_fields = [ + 'id', 'user', 'k8sapppermission' + ] + search_fields = ('user__name', 'user__username', 'k8sapppermission__name') + + def get_queryset(self): + queryset = super().get_queryset() + queryset = queryset.annotate(user_display=F('user__name')) + return queryset + + +class K8sAppPermissionUserGroupRelationViewSet(RelationViewSet): + serializer_class = serializers.K8sAppPermissionUserGroupRelationSerializer + m2m_field = models.K8sAppPermission.user_groups.field + permission_classes = (IsOrgAdmin,) + filter_fields = [ + 'id', "usergroup", "k8sapppermission" + ] + search_fields = ["usergroup__name", "k8sapppermission__name"] + + def get_queryset(self): + queryset = super().get_queryset() + queryset = queryset \ + .annotate(usergroup_display=F('usergroup__name')) + return queryset + + +class K8sAppPermissionAllUserListApi(generics.ListAPIView): + permission_classes = (IsOrgAdmin,) + serializer_class = serializers.K8sAppPermissionAllUserSerializer + filter_fields = ("username", "name") + search_fields = filter_fields + + def get_queryset(self): + pk = self.kwargs.get("pk") + perm = get_object_or_404(models.K8sAppPermission, pk=pk) + users = perm.get_all_users().only( + *self.serializer_class.Meta.only_fields + ) + return users + + +class K8sAppPermissionK8sAppRelationViewSet(RelationViewSet): + serializer_class = serializers.K8sAppPermissionK8sAppRelationSerializer + m2m_field = models.K8sAppPermission.k8s_apps.field + permission_classes = (IsOrgAdmin,) + filter_fields = [ + 'id', 'k8sapp', 'k8sapppermission', + ] + search_fields = [ + "id", "k8sapp__name", "k8sapppermission__name" + ] + + def get_queryset(self): + queryset = super().get_queryset() + queryset = queryset \ + .annotate(k8sapp_display=F('k8sapp__name')) + return queryset + + +class K8sAppPermissionAllK8sAppListApi(generics.ListAPIView): + permission_classes = (IsOrgAdmin,) + serializer_class = serializers.K8sAppPermissionAllK8sAppSerializer + filter_fields = ("name",) + search_fields = filter_fields + + def get_queryset(self): + pk = self.kwargs.get("pk") + perm = get_object_or_404(models.K8sAppPermission, pk=pk) + database_apps = perm.get_all_k8s_apps().only( + *self.serializer_class.Meta.only_fields + ) + return database_apps + + +class K8sAppPermissionSystemUserRelationViewSet(RelationViewSet): + serializer_class = serializers.K8sAppPermissionSystemUserRelationSerializer + m2m_field = models.K8sAppPermission.system_users.field + permission_classes = (IsOrgAdmin,) + filter_fields = [ + 'id', 'systemuser', 'k8sapppermission' + ] + search_fields = [ + 'k8sapppermission__name', 'systemuser__name', 'systemuser__username' + ] + + def get_queryset(self): + queryset = super().get_queryset() + queryset = queryset.annotate( + systemuser_display=Concat( + F('systemuser__name'), Value('('), F('systemuser__username'), + Value(')') + ) + ) + return queryset diff --git a/apps/perms/api/mixin.py b/apps/perms/api/mixin.py index cbbfa825f..43c96dc01 100644 --- a/apps/perms/api/mixin.py +++ b/apps/perms/api/mixin.py @@ -21,7 +21,7 @@ class UserPermissionMixin: obj = None def initial(self, *args, **kwargs): - super().initial(*args, *kwargs) + super().initial(*args, **kwargs) self.obj = self.get_obj() def get_obj(self): diff --git a/apps/perms/api/user_k8s_app_permission.py b/apps/perms/api/user_k8s_app_permission.py new file mode 100644 index 000000000..60f28d84c --- /dev/null +++ b/apps/perms/api/user_k8s_app_permission.py @@ -0,0 +1,119 @@ +# coding: utf-8 +# + +import uuid +from django.shortcuts import get_object_or_404 +from rest_framework.views import APIView, Response +from common.permissions import IsOrgAdminOrAppUser, IsValidUser +from common.tree import TreeNodeSerializer +from orgs.mixins import generics +from users.models import User, UserGroup +from applications.serializers import K8sAppSerializer +from applications.models import K8sApp +from assets.models import SystemUser +from .. import utils, serializers +from .mixin import UserPermissionMixin + + +class UserGrantedK8sAppsApi(generics.ListAPIView): + permission_classes = (IsOrgAdminOrAppUser,) + serializer_class = K8sAppSerializer + filter_fields = ['id', 'name', 'type', 'comment'] + search_fields = ['name', 'comment'] + + def get_object(self): + user_id = self.kwargs.get('pk', '') + if user_id: + user = get_object_or_404(User, id=user_id) + else: + user = self.request.user + return user + + def get_queryset(self): + util = utils.K8sAppPermissionUtil(self.get_object()) + queryset = util.get_k8s_apps() + return queryset + + def get_permissions(self): + if self.kwargs.get('pk') is None: + self.permission_classes = (IsValidUser,) + return super().get_permissions() + + +class UserGrantedK8sAppsAsTreeApi(UserGrantedK8sAppsApi): + serializer_class = TreeNodeSerializer + permission_classes = (IsOrgAdminOrAppUser,) + + def get_serializer(self, k8s_apps, *args, **kwargs): + if k8s_apps is None: + k8s_apps = [] + only_k8s_app = self.request.query_params.get('only', '0') == '1' + tree_root = None + data = [] + if not only_k8s_app: + tree_root = utils.construct_k8s_apps_tree_root() + data.append(tree_root) + for k8s_app in k8s_apps: + node = utils.parse_k8s_app_to_tree_node(tree_root, k8s_app) + data.append(node) + data.sort() + return super().get_serializer(data, many=True) + + +class UserGrantedK8sAppSystemUsersApi(UserPermissionMixin, generics.ListAPIView): + permission_classes = (IsOrgAdminOrAppUser,) + serializer_class = serializers.K8sAppSystemUserSerializer + only_fields = serializers.K8sAppSystemUserSerializer.Meta.only_fields + + def get_queryset(self): + util = utils.K8sAppPermissionUtil(self.obj) + k8s_app_id = self.kwargs.get('k8s_app_id') + k8s_app = get_object_or_404(K8sApp, id=k8s_app_id) + system_users = util.get_k8s_app_system_users(k8s_app) + return system_users + + +# Validate + +class ValidateUserK8sAppPermissionApi(APIView): + permission_classes = (IsOrgAdminOrAppUser,) + + def get(self, request, *args, **kwargs): + user_id = request.query_params.get('user_id', '') + k8s_app_id = request.query_params.get('k8s_app_id', '') + system_user_id = request.query_params.get('system_user_id', '') + + try: + user_id = uuid.UUID(user_id) + k8s_app_id = uuid.UUID(k8s_app_id) + system_user_id = uuid.UUID(system_user_id) + except ValueError: + return Response({'msg': False}, status=403) + + user = get_object_or_404(User, id=user_id) + k8s_app = get_object_or_404(K8sApp, id=k8s_app_id) + system_user = get_object_or_404(SystemUser, id=system_user_id) + + util = utils.K8sAppPermissionUtil(user) + system_users = util.get_k8s_app_system_users(k8s_app) + if system_user in system_users: + return Response({'msg': True}, status=200) + + return Response({'msg': False}, status=403) + + +# UserGroup + +class UserGroupGrantedK8sAppsApi(generics.ListAPIView): + permission_classes = (IsOrgAdminOrAppUser,) + serializer_class = K8sAppSerializer + + def get_queryset(self): + queryset = [] + user_group_id = self.kwargs.get('pk') + if not user_group_id: + return queryset + user_group = get_object_or_404(UserGroup, id=user_group_id) + util = utils.K8sAppPermissionUtil(user_group) + queryset = util.get_k8s_apps() + return queryset diff --git a/apps/perms/migrations/0011_auto_20200721_1739.py b/apps/perms/migrations/0011_auto_20200721_1739.py new file mode 100644 index 000000000..7e6b37188 --- /dev/null +++ b/apps/perms/migrations/0011_auto_20200721_1739.py @@ -0,0 +1,28 @@ +# Generated by Django 2.2.10 on 2020-07-21 09:39 + +from django.db import migrations, models + +from django.db.models import F +from ..models.asset_permission import Action + + +def migrate_asset_permission(apps, schema_editor): + # 已有的资产权限默认拥有剪切板复制粘贴动作 + AssetPermission = apps.get_model('perms', 'AssetPermission') + AssetPermission.objects.all().update(actions=F('actions').bitor(Action.CLIPBOARD_COPY_PASTE)) + + +class Migration(migrations.Migration): + + dependencies = [ + ('perms', '0010_auto_20191218_1705'), + ] + + operations = [ + migrations.AlterField( + model_name='assetpermission', + name='actions', + field=models.IntegerField(choices=[(255, 'All'), (1, 'Connect'), (2, 'Upload file'), (4, 'Download file'), (6, 'Upload download'), (8, 'Clipboard copy'), (16, 'Clipboard paste'), (24, 'Clipboard copy paste')], default=255, verbose_name='Actions'), + ), + migrations.RunPython(migrate_asset_permission) + ] diff --git a/apps/perms/migrations/0012_k8sapppermission.py b/apps/perms/migrations/0012_k8sapppermission.py new file mode 100644 index 000000000..c13b75ad1 --- /dev/null +++ b/apps/perms/migrations/0012_k8sapppermission.py @@ -0,0 +1,44 @@ +# Generated by Django 2.2.13 on 2020-08-07 07:13 + +import common.utils.django +from django.conf import settings +from django.db import migrations, models +import django.utils.timezone +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('assets', '0054_auto_20200807_1032'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('applications', '0005_k8sapp'), + ('users', '0028_auto_20200728_1805'), + ('perms', '0011_auto_20200721_1739'), + ] + + operations = [ + migrations.CreateModel( + name='K8sAppPermission', + fields=[ + ('org_id', models.CharField(blank=True, db_index=True, default='', max_length=36, verbose_name='Organization')), + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=128, verbose_name='Name')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('date_start', models.DateTimeField(db_index=True, default=django.utils.timezone.now, verbose_name='Date start')), + ('date_expired', models.DateTimeField(db_index=True, default=common.utils.django.date_expired_default, verbose_name='Date expired')), + ('created_by', models.CharField(blank=True, max_length=128, verbose_name='Created by')), + ('date_created', models.DateTimeField(auto_now_add=True, verbose_name='Date created')), + ('comment', models.TextField(blank=True, verbose_name='Comment')), + ('k8s_apps', models.ManyToManyField(blank=True, related_name='granted_by_permissions', to='applications.K8sApp', verbose_name='KubernetesApp')), + ('system_users', models.ManyToManyField(related_name='granted_by_k8s_app_permissions', to='assets.SystemUser', verbose_name='System user')), + ('user_groups', models.ManyToManyField(blank=True, to='users.UserGroup', verbose_name='User group')), + ('users', models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'verbose_name': 'KubernetesApp permission', + 'ordering': ('name',), + 'unique_together': {('org_id', 'name')}, + }, + ), + ] diff --git a/apps/perms/models/__init__.py b/apps/perms/models/__init__.py index c6581b858..264c14787 100644 --- a/apps/perms/models/__init__.py +++ b/apps/perms/models/__init__.py @@ -4,3 +4,4 @@ from .asset_permission import * from .remote_app_permission import * from .database_app_permission import * +from .k8s_app_permission import * diff --git a/apps/perms/models/asset_permission.py b/apps/perms/models/asset_permission.py index 8552edc74..f2755a568 100644 --- a/apps/perms/models/asset_permission.py +++ b/apps/perms/models/asset_permission.py @@ -21,11 +21,15 @@ logger = logging.getLogger(__name__) class Action: NONE = 0 - CONNECT = 0b00000001 - UPLOAD = 0b00000010 - DOWNLOAD = 0b00000100 + + CONNECT = 0b1 + UPLOAD = 0b1 << 1 + DOWNLOAD = 0b1 << 2 + CLIPBOARD_COPY = 0b1 << 3 + CLIPBOARD_PASTE = 0b1 << 4 + ALL = 0xff UPDOWNLOAD = UPLOAD | DOWNLOAD - ALL = 0b11111111 + CLIPBOARD_COPY_PASTE = CLIPBOARD_COPY | CLIPBOARD_PASTE DB_CHOICES = ( (ALL, _('All')), @@ -33,6 +37,9 @@ class Action: (UPLOAD, _('Upload file')), (DOWNLOAD, _('Download file')), (UPDOWNLOAD, _("Upload download")), + (CLIPBOARD_COPY, _('Clipboard copy')), + (CLIPBOARD_PASTE, _('Clipboard paste')), + (CLIPBOARD_COPY_PASTE, _('Clipboard copy paste')) ) NAME_MAP = { @@ -41,9 +48,12 @@ class Action: UPLOAD: "upload_file", DOWNLOAD: "download_file", UPDOWNLOAD: "updownload", + CLIPBOARD_COPY: 'clipboard_copy', + CLIPBOARD_PASTE: 'clipboard_paste', + CLIPBOARD_COPY_PASTE: 'clipboard_copy_paste' } - NAME_MAP_REVERSE = dict({v: k for k, v in NAME_MAP.items()}) + NAME_MAP_REVERSE = {v: k for k, v in NAME_MAP.items()} CHOICES = [] for i, j in DB_CHOICES: CHOICES.append((NAME_MAP[i], j)) diff --git a/apps/perms/models/k8s_app_permission.py b/apps/perms/models/k8s_app_permission.py new file mode 100644 index 000000000..b6c7106bc --- /dev/null +++ b/apps/perms/models/k8s_app_permission.py @@ -0,0 +1,39 @@ +# coding: utf-8 +# + +from django.db import models +from django.utils.translation import ugettext_lazy as _ + +from common.utils import lazyproperty +from .base import BasePermission + +__all__ = [ + 'K8sAppPermission', +] + + +class K8sAppPermission(BasePermission): + k8s_apps = models.ManyToManyField( + 'applications.K8sApp', related_name='granted_by_permissions', + blank=True, verbose_name=_("KubernetesApp") + ) + system_users = models.ManyToManyField( + 'assets.SystemUser', related_name='granted_by_k8s_app_permissions', + verbose_name=_("System user") + ) + + class Meta: + unique_together = [('org_id', 'name')] + verbose_name = _('KubernetesApp permission') + ordering = ('name',) + + def get_all_k8s_apps(self): + return self.k8s_apps.all() + + @lazyproperty + def k8s_apps_amount(self): + return self.k8s_apps.count() + + @lazyproperty + def system_users_amount(self): + return self.system_users.count() diff --git a/apps/perms/serializers/__init__.py b/apps/perms/serializers/__init__.py index 43f221d6e..e233a4d5b 100644 --- a/apps/perms/serializers/__init__.py +++ b/apps/perms/serializers/__init__.py @@ -9,3 +9,5 @@ from .asset_permission_relation import * from .database_app_permission import * from .database_app_permission_relation import * from .base import * +from .k8s_app_permission import * +from .k8s_app_permission_relation import * diff --git a/apps/perms/serializers/k8s_app_permission.py b/apps/perms/serializers/k8s_app_permission.py new file mode 100644 index 000000000..0c836bc27 --- /dev/null +++ b/apps/perms/serializers/k8s_app_permission.py @@ -0,0 +1,50 @@ +# coding: utf-8 +# +from django.db.models import Count +from rest_framework import serializers + +from orgs.mixins.serializers import BulkOrgResourceModelSerializer +from .. import models + +__all__ = [ + 'K8sAppPermissionSerializer', 'K8sAppPermissionListSerializer' +] + + +class AmountMixin: + @classmethod + def setup_eager_loading(cls, queryset): + """ Perform necessary eager loading of data. """ + queryset = queryset.annotate( + users_amount=Count('users', distinct=True), user_groups_amount=Count('user_groups', distinct=True), + k8s_apps_amount=Count('k8s_apps', distinct=True), + system_users_amount=Count('system_users', distinct=True) + ) + return queryset + + +class K8sAppPermissionSerializer(AmountMixin, BulkOrgResourceModelSerializer): + class Meta: + model = models.K8sAppPermission + fields = [ + 'id', 'name', 'users', 'user_groups', 'k8s_apps', 'system_users', + 'comment', 'is_active', 'date_start', 'date_expired', 'is_valid', + 'created_by', 'date_created', 'users_amount', 'user_groups_amount', + 'k8s_apps_amount', 'system_users_amount', + ] + read_only_fields = [ + 'created_by', 'date_created', 'users_amount', 'user_groups_amount', + 'k8s_apps_amount', 'system_users_amount', 'id' + ] + + +class K8sAppPermissionListSerializer(AmountMixin, BulkOrgResourceModelSerializer): + is_expired = serializers.BooleanField() + + class Meta: + model = models.K8sAppPermission + fields = [ + 'id', 'name', 'comment', 'is_active', 'users_amount', 'user_groups_amount', + 'date_start', 'date_expired', 'is_valid', 'k8s_apps_amount', 'system_users_amount', + 'created_by', 'date_created', 'is_expired' + ] diff --git a/apps/perms/serializers/k8s_app_permission_relation.py b/apps/perms/serializers/k8s_app_permission_relation.py new file mode 100644 index 000000000..e5786ca50 --- /dev/null +++ b/apps/perms/serializers/k8s_app_permission_relation.py @@ -0,0 +1,73 @@ +# coding: utf-8 +# +from perms.serializers.base import PermissionAllUserSerializer +from rest_framework import serializers + +from common.drf.serializers import BulkModelSerializer + +from .. import models + + +class K8sAppPermissionUserRelationSerializer(BulkModelSerializer): + user_display = serializers.ReadOnlyField() + k8sapppermission_display = serializers.ReadOnlyField() + + class Meta: + model = models.K8sAppPermission.users.through + fields = [ + 'id', 'user', 'user_display', 'k8sapppermission', + 'k8sapppermission_display' + ] + + +class K8sAppPermissionUserGroupRelationSerializer(BulkModelSerializer): + usergroup_display = serializers.ReadOnlyField() + k8sapppermission_display = serializers.ReadOnlyField() + + class Meta: + model = models.K8sAppPermission.user_groups.through + fields = [ + 'id', 'usergroup', 'usergroup_display', 'k8sapppermission', + 'k8sapppermission_display' + ] + + +class K8sAppPermissionAllUserSerializer(PermissionAllUserSerializer): + class Meta(PermissionAllUserSerializer.Meta): + pass + + +class K8sAppPermissionK8sAppRelationSerializer(BulkModelSerializer): + k8sapp_display = serializers.ReadOnlyField() + k8sapppermission_display = serializers.ReadOnlyField() + + class Meta: + model = models.K8sAppPermission.k8s_apps.through + fields = [ + 'id', "k8sapp", "k8sapp_display", 'k8sapppermission', + 'k8sapppermission_display' + ] + + +class K8sAppPermissionAllK8sAppSerializer(serializers.Serializer): + k8sapp = serializers.UUIDField(read_only=True, source='id') + k8sapp_display = serializers.SerializerMethodField() + + class Meta: + only_fields = ['id', 'name'] + + @staticmethod + def get_k8sapp_display(obj): + return str(obj) + + +class K8sAppPermissionSystemUserRelationSerializer(BulkModelSerializer): + systemuser_display = serializers.ReadOnlyField() + k8sapppermission_display = serializers.ReadOnlyField() + + class Meta: + model = models.K8sAppPermission.system_users.through + fields = [ + 'id', 'systemuser', 'systemuser_display', 'k8sapppermission', + 'k8sapppermission_display' + ] diff --git a/apps/perms/serializers/user_permission.py b/apps/perms/serializers/user_permission.py index ac248947f..11d1bcfc7 100644 --- a/apps/perms/serializers/user_permission.py +++ b/apps/perms/serializers/user_permission.py @@ -14,6 +14,7 @@ __all__ = [ 'ActionsSerializer', 'AssetSystemUserSerializer', 'RemoteAppSystemUserSerializer', 'DatabaseAppSystemUserSerializer', + 'K8sAppSystemUserSerializer', ] @@ -53,6 +54,16 @@ class DatabaseAppSystemUserSerializer(serializers.ModelSerializer): read_only_fields = fields +class K8sAppSystemUserSerializer(serializers.ModelSerializer): + class Meta: + model = SystemUser + only_fields = ( + 'id', 'name', 'username', 'priority', 'protocol', 'login_mode', + ) + fields = list(only_fields) + read_only_fields = fields + + class AssetGrantedSerializer(serializers.ModelSerializer): """ 被授权资产的数据结构 diff --git a/apps/perms/urls/api_urls.py b/apps/perms/urls/api_urls.py index d70a824cc..f2b87ca72 100644 --- a/apps/perms/urls/api_urls.py +++ b/apps/perms/urls/api_urls.py @@ -6,6 +6,7 @@ from .asset_permission import asset_permission_urlpatterns from .remote_app_permission import remote_app_permission_urlpatterns from .database_app_permission import database_app_permission_urlpatterns from .system_user_permission import system_users_permission_urlpatterns +from .k8s_app_permission import k8s_app_permission_urlpatterns app_name = 'perms' @@ -16,5 +17,6 @@ old_version_urlpatterns = [ urlpatterns = asset_permission_urlpatterns + \ remote_app_permission_urlpatterns + \ database_app_permission_urlpatterns + \ + k8s_app_permission_urlpatterns + \ old_version_urlpatterns + \ system_users_permission_urlpatterns diff --git a/apps/perms/urls/k8s_app_permission.py b/apps/perms/urls/k8s_app_permission.py new file mode 100644 index 000000000..2c145948b --- /dev/null +++ b/apps/perms/urls/k8s_app_permission.py @@ -0,0 +1,45 @@ +# coding: utf-8 +# + +from django.urls import path, include +from rest_framework_bulk.routes import BulkRouter +from .. import api + + +router = BulkRouter() +router.register('k8s-app-permissions', api.K8sAppPermissionViewSet, 'k8s-app-permission') +router.register('k8s-app-permissions-users-relations', api.K8sAppPermissionUserRelationViewSet, 'k8s-app-permissions-users-relation') +router.register('k8s-app-permissions-user-groups-relations', api.K8sAppPermissionUserGroupRelationViewSet, 'k8s-app-permissions-user-groups-relation') +router.register('k8s-app-permissions-k8s-apps-relations', api.K8sAppPermissionK8sAppRelationViewSet, 'k8s-app-permissions-k8s-apps-relation') +router.register('k8s-app-permissions-system-users-relations', api.K8sAppPermissionSystemUserRelationViewSet, 'k8s-app-permissions-system-users-relation') + +user_permission_urlpatterns = [ + path('/k8s-apps/', api.UserGrantedK8sAppsApi.as_view(), name='user-k8s-apps'), + path('k8s-apps/', api.UserGrantedK8sAppsApi.as_view(), name='my-k8s-apps'), + + # k8sApps as tree + path('/k8s-apps/tree/', api.UserGrantedK8sAppsAsTreeApi.as_view(), name='user-k8ss-apps-tree'), + path('k8s-apps/tree/', api.UserGrantedK8sAppsAsTreeApi.as_view(), name='my-k8ss-apps-tree'), + + path('/k8s-apps//system-users/', api.UserGrantedK8sAppSystemUsersApi.as_view(), name='user-k8s-app-system-users'), + path('k8s-apps//system-users/', api.UserGrantedK8sAppSystemUsersApi.as_view(), name='user-k8s-app-system-users'), +] + +user_group_permission_urlpatterns = [ + path('/k8s-apps/', api.UserGroupGrantedK8sAppsApi.as_view(), name='user-group-k8s-apps'), +] + +permission_urlpatterns = [ + path('/users/all/', api.K8sAppPermissionAllUserListApi.as_view(), name='k8s-app-permission-all-users'), + path('/k8s-apps/all/', api.K8sAppPermissionAllK8sAppListApi.as_view(), name='k8s-app-permission-all-k8s-apps'), + + path('user/validate/', api.ValidateUserK8sAppPermissionApi.as_view(), name='validate-user-k8s-app-permission'), +] + +k8s_app_permission_urlpatterns = [ + path('users/', include(user_permission_urlpatterns)), + path('user-groups/', include(user_group_permission_urlpatterns)), + path('k8s-app-permissions/', include(permission_urlpatterns)) +] + +k8s_app_permission_urlpatterns += router.urls diff --git a/apps/perms/utils/__init__.py b/apps/perms/utils/__init__.py index c6581b858..35e29adb6 100644 --- a/apps/perms/utils/__init__.py +++ b/apps/perms/utils/__init__.py @@ -4,3 +4,4 @@ from .asset_permission import * from .remote_app_permission import * from .database_app_permission import * +from .k8s_app_permission import * \ No newline at end of file diff --git a/apps/perms/utils/k8s_app_permission.py b/apps/perms/utils/k8s_app_permission.py new file mode 100644 index 000000000..578fa6380 --- /dev/null +++ b/apps/perms/utils/k8s_app_permission.py @@ -0,0 +1,93 @@ +# coding: utf-8 +# + +from django.utils.translation import ugettext as _ +from django.db.models import Q + +from orgs.utils import set_to_root_org +from ..models import K8sAppPermission +from common.tree import TreeNode +from applications.models import K8sApp +from assets.models import SystemUser + + +def get_user_k8s_app_permissions(user, include_group=True): + if include_group: + groups = user.groups.all() + arg = Q(users=user) | Q(user_groups__in=groups) + else: + arg = Q(users=user) + return K8sAppPermission.objects.all().valid().filter(arg) + + +def get_user_group_k8s_app_permission(user_group): + return K8sAppPermission.objects.all().valid().filter( + user_groups=user_group + ) + + +class K8sAppPermissionUtil: + get_permissions_map = { + 'User': get_user_k8s_app_permissions, + 'UserGroup': get_user_group_k8s_app_permission + } + + def __init__(self, obj): + self.object = obj + self.change_org_if_need() + + @staticmethod + def change_org_if_need(): + set_to_root_org() + + @property + def permissions(self): + obj_class = self.object.__class__.__name__ + func = self.get_permissions_map[obj_class] + _permissions = func(self.object) + return _permissions + + def get_k8s_apps(self): + k8s_apps = K8sApp.objects.filter( + granted_by_permissions__in=self.permissions + ).distinct() + return k8s_apps + + def get_k8s_app_system_users(self, k8s_app): + queryset = self.permissions + kwargs = {'k8s_apps': k8s_app} + queryset = queryset.filter(**kwargs) + system_users_ids = queryset.values_list('system_users', flat=True) + system_users_ids = system_users_ids.distinct() + system_users = SystemUser.objects.filter(id__in=system_users_ids) + system_users = system_users.order_by('-priority') + return system_users + + +def construct_k8s_apps_tree_root(): + tree_root = { + 'id': 'ID_K8S_APP_ROOT', + 'name': _('KubernetesApp'), + 'title': 'K8sApp', + 'pId': '', + 'open': False, + 'isParent': True, + 'iconSkin': '', + 'meta': {'type': 'k8s_app'} + } + return TreeNode(**tree_root) + + +def parse_k8s_app_to_tree_node(parent, k8s_app): + pid = parent.id if parent else '' + tree_node = { + 'id': k8s_app.id, + 'name': k8s_app.name, + 'title': k8s_app.name, + 'pId': pid, + 'open': False, + 'isParent': False, + 'iconSkin': 'file', + 'meta': {'type': 'k8s_app'} + } + return TreeNode(**tree_node) diff --git a/apps/terminal/api/terminal.py b/apps/terminal/api/terminal.py index 705848fe0..2ee353e3e 100644 --- a/apps/terminal/api/terminal.py +++ b/apps/terminal/api/terminal.py @@ -5,17 +5,17 @@ import logging import uuid from django.core.cache import cache -from django.shortcuts import get_object_or_404, redirect -from django.utils import timezone +from django.shortcuts import get_object_or_404 from rest_framework import viewsets from rest_framework.views import APIView, Response from rest_framework.permissions import AllowAny - +from common.drf.api import JMSBulkModelViewSet from common.utils import get_object_or_none from common.permissions import IsAppUser, IsOrgAdminOrAppUser, IsSuperUser from ..models import Terminal, Status, Session from .. import serializers +from .. import exceptions __all__ = [ 'TerminalViewSet', 'TerminalTokenApi', 'StatusViewSet', 'TerminalConfig', @@ -23,13 +23,16 @@ __all__ = [ logger = logging.getLogger(__file__) -class TerminalViewSet(viewsets.ModelViewSet): +class TerminalViewSet(JMSBulkModelViewSet): queryset = Terminal.objects.filter(is_deleted=False) serializer_class = serializers.TerminalSerializer permission_classes = (IsSuperUser,) filter_fields = ['name', 'remote_addr'] def create(self, request, *args, **kwargs): + if isinstance(request.data, list): + raise exceptions.BulkCreateNotSupport() + name = request.data.get('name') remote_ip = request.META.get('REMOTE_ADDR') x_real_ip = request.META.get('X-Real-IP') diff --git a/apps/terminal/exceptions.py b/apps/terminal/exceptions.py new file mode 100644 index 000000000..a3b63a3e9 --- /dev/null +++ b/apps/terminal/exceptions.py @@ -0,0 +1,8 @@ +from django.utils.translation import ugettext_lazy as _ + +from common.exceptions import JMSException + + +class BulkCreateNotSupport(JMSException): + default_code = 'bulk_create_not_support' + default_detail = _('Bulk create not support') diff --git a/apps/terminal/migrations/0025_auto_20200810_1735.py b/apps/terminal/migrations/0025_auto_20200810_1735.py new file mode 100644 index 000000000..96a06915c --- /dev/null +++ b/apps/terminal/migrations/0025_auto_20200810_1735.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.13 on 2020-08-10 09:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('terminal', '0024_auto_20200715_1713'), + ] + + operations = [ + migrations.AlterField( + model_name='session', + name='protocol', + field=models.CharField(choices=[('ssh', 'ssh'), ('rdp', 'rdp'), ('vnc', 'vnc'), ('telnet', 'telnet'), ('mysql', 'mysql'), ('k8s', 'kubernetes')], db_index=True, default='ssh', max_length=8), + ), + ] diff --git a/apps/terminal/models.py b/apps/terminal/models.py index f3914cc1c..6fdf8e349 100644 --- a/apps/terminal/models.py +++ b/apps/terminal/models.py @@ -16,6 +16,7 @@ from users.models import User from orgs.mixins.models import OrgModelMixin from common.mixins import CommonModelMixin from common.fields.model import EncryptJsonDictTextField +from common.db.models import ChoiceSet from .backends import get_multi_command_storage from .backends.command.models import AbstractSessionCommand from . import const @@ -169,17 +170,17 @@ class Status(models.Model): class Session(OrgModelMixin): - LOGIN_FROM_CHOICES = ( - ('ST', 'SSH Terminal'), - ('WT', 'Web Terminal'), - ) - PROTOCOL_CHOICES = ( - ('ssh', 'ssh'), - ('rdp', 'rdp'), - ('vnc', 'vnc'), - ('telnet', 'telnet'), - ('mysql', 'mysql'), - ) + class LOGIN_FROM(ChoiceSet): + ST = 'ST', 'SSH Terminal' + WT = 'WT', 'Web Terminal' + + class PROTOCOL(ChoiceSet): + SSH = 'ssh', 'ssh' + RDP = 'rdp', 'rdp' + VNC = 'vnc', 'vnc' + TELNET = 'telnet', 'telnet' + MYSQL = 'mysql', 'mysql' + K8S = 'k8s', 'kubernetes' id = models.UUIDField(default=uuid.uuid4, primary_key=True) user = models.CharField(max_length=128, verbose_name=_("User"), db_index=True) @@ -188,14 +189,14 @@ class Session(OrgModelMixin): asset_id = models.CharField(blank=True, default='', max_length=36, db_index=True) system_user = models.CharField(max_length=128, verbose_name=_("System user"), db_index=True) system_user_id = models.CharField(blank=True, default='', max_length=36, db_index=True) - login_from = models.CharField(max_length=2, choices=LOGIN_FROM_CHOICES, default="ST", verbose_name=_("Login from")) + login_from = models.CharField(max_length=2, choices=LOGIN_FROM.choices, default="ST", verbose_name=_("Login from")) remote_addr = models.CharField(max_length=128, verbose_name=_("Remote addr"), blank=True, null=True) is_success = models.BooleanField(default=True, db_index=True) is_finished = models.BooleanField(default=False, db_index=True) has_replay = models.BooleanField(default=False, verbose_name=_("Replay")) has_command = models.BooleanField(default=False, verbose_name=_("Command")) terminal = models.ForeignKey(Terminal, null=True, on_delete=models.SET_NULL) - protocol = models.CharField(choices=PROTOCOL_CHOICES, default='ssh', max_length=8, db_index=True) + protocol = models.CharField(choices=PROTOCOL.choices, default='ssh', max_length=8, db_index=True) date_start = models.DateTimeField(verbose_name=_("Date start"), db_index=True, default=timezone.now) date_end = models.DateTimeField(verbose_name=_("Date end"), null=True) @@ -245,9 +246,10 @@ class Session(OrgModelMixin): @property def can_join(self): + _PROTOCOL = self.PROTOCOL if self.is_finished: return False - if self.protocol not in ['ssh', 'telnet', 'mysql']: + if self.protocol not in [_PROTOCOL.SSH, _PROTOCOL.TELNET, _PROTOCOL.MYSQL, _PROTOCOL.K8S]: return False return True diff --git a/apps/terminal/serializers/terminal.py b/apps/terminal/serializers/terminal.py index c7a91d009..b643dd16a 100644 --- a/apps/terminal/serializers/terminal.py +++ b/apps/terminal/serializers/terminal.py @@ -1,13 +1,12 @@ from rest_framework import serializers -from common.mixins import BulkSerializerMixin -from common.serializers import AdaptedBulkListSerializer +from common.drf.serializers import BulkModelSerializer, AdaptedBulkListSerializer from ..models import ( Terminal, Status, Session, Task ) -class TerminalSerializer(serializers.ModelSerializer): +class TerminalSerializer(BulkModelSerializer): session_online = serializers.SerializerMethodField() is_alive = serializers.BooleanField(read_only=True) @@ -30,7 +29,7 @@ class StatusSerializer(serializers.ModelSerializer): model = Status -class TaskSerializer(BulkSerializerMixin, serializers.ModelSerializer): +class TaskSerializer(BulkModelSerializer): class Meta: fields = '__all__' diff --git a/apps/terminal/urls/api_urls.py b/apps/terminal/urls/api_urls.py index d82a5ca5a..c60f97603 100644 --- a/apps/terminal/urls/api_urls.py +++ b/apps/terminal/urls/api_urls.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- # -from django.urls import path, include, re_path +from django.urls import path, re_path from rest_framework_bulk.routes import BulkRouter from common import api as capi diff --git a/apps/terminal/utils.py b/apps/terminal/utils.py index cb8308fcd..2ea2768bc 100644 --- a/apps/terminal/utils.py +++ b/apps/terminal/utils.py @@ -20,7 +20,7 @@ def get_session_asset_list(): def get_session_user_list(): - return User.objects.exclude(role=User.ROLE_APP).values_list('username', flat=True) + return User.objects.exclude(role=User.ROLE.APP).values_list('username', flat=True) def get_session_system_user_list(): diff --git a/apps/tickets/api/request_asset_perm.py b/apps/tickets/api/request_asset_perm.py index 40add8659..5b62b8dcf 100644 --- a/apps/tickets/api/request_asset_perm.py +++ b/apps/tickets/api/request_asset_perm.py @@ -1,22 +1,23 @@ -from collections import namedtuple - -from django.db.transaction import atomic -from django.db.models import F +from django.db.models import Q from django.utils.translation import ugettext_lazy as _ from rest_framework.decorators import action from rest_framework.response import Response +from rest_framework.request import Request +from orgs.models import Organization, ROLE as ORG_ROLE from users.models.user import User from common.const.http import POST, GET from common.drf.api import JMSModelViewSet from common.permissions import IsValidUser from common.utils.django import get_object_or_none +from common.utils.timezone import dt_parser from common.drf.serializers import EmptySerializer from perms.models.asset_permission import AssetPermission, Asset +from perms.models import Action from assets.models.user import SystemUser from ..exceptions import ( ConfirmedAssetsChanged, ConfirmedSystemUserChanged, - TicketClosed, TicketActionYet, NotHaveConfirmedAssets, + TicketClosed, TicketActionAlready, NotHaveConfirmedAssets, NotHaveConfirmedSystemUser ) from .. import serializers @@ -25,71 +26,61 @@ from ..permissions import IsAssignee class RequestAssetPermTicketViewSet(JMSModelViewSet): - queryset = Ticket.objects.filter(type=Ticket.TYPE_REQUEST_ASSET_PERM) + queryset = Ticket.origin_objects.filter(type=Ticket.TYPE.REQUEST_ASSET_PERM) serializer_classes = { 'default': serializers.RequestAssetPermTicketSerializer, 'approve': EmptySerializer, 'reject': EmptySerializer, - 'assignees': serializers.OrgAssigneeSerializer, + 'assignees': serializers.AssigneeSerializer, } permission_classes = (IsValidUser,) - filter_fields = ['status', 'title', 'action', 'user_display'] + filter_fields = ['status', 'title', 'action', 'user_display', 'org_id'] search_fields = ['user_display', 'title'] def _check_can_set_action(self, instance, action): - if instance.status == instance.STATUS_CLOSED: - raise TicketClosed(detail=_('Ticket closed')) + if instance.status == instance.STATUS.CLOSED: + raise TicketClosed if instance.action == action: - action_display = dict(instance.ACTION_CHOICES).get(action) - raise TicketActionYet(detail=_('Ticket has %s') % action_display) + action_display = instance.ACTION.get(action) + raise TicketActionAlready(detail=_('Ticket has %s') % action_display) @action(detail=False, methods=[GET], permission_classes=[IsValidUser]) - def assignees(self, request, *args, **kwargs): - org_mapper = {} - UserTuple = namedtuple('UserTuple', ('id', 'name', 'username')) + def assignees(self, request: Request, *args, **kwargs): user = request.user - superusers = User.objects.filter(role=User.ROLE_ADMIN) + org_id = request.query_params.get('org_id', Organization.DEFAULT_ID) - admins_with_org = User.objects.filter(related_admin_orgs__users=user).annotate( - org_id=F('related_admin_orgs__id'), org_name=F('related_admin_orgs__name') - ) + q = Q(role=User.ROLE.ADMIN) + if org_id != Organization.DEFAULT_ID: + q |= Q(m2m_org_members__role=ORG_ROLE.ADMIN, orgs__id=org_id, orgs__members=user) + org_admins = User.objects.filter(q).distinct() - for user in admins_with_org: - org_id = user.org_id + return self.get_paginated_response_with_query_set(org_admins) - if org_id not in org_mapper: - org_mapper[org_id] = { - 'org_name': user.org_name, - 'org_admins': set() # 去重 - } - org_mapper[org_id]['org_admins'].add(UserTuple(user.id, user.name, user.username)) + def _get_extra_comment(self, instance): + meta = instance.meta + ips = ', '.join(meta.get('ips', [])) + confirmed_assets = ', '.join(meta.get('confirmed_assets', [])) - result = [ - { - 'org_name': _('Superuser'), - 'org_admins': set(UserTuple(user.id, user.name, user.username) - for user in superusers) - } - ] - - for org in org_mapper.values(): - result.append(org) - serializer_class = self.get_serializer_class() - serilizer = serializer_class(instance=result, many=True) - return Response(data=serilizer.data) + return f''' + {_('IP group')}: {ips} + {_('Hostname')}: {meta.get('hostname', '')} + {_('System user')}: {meta.get('system_user', '')} + {_('Confirmed assets')}: {confirmed_assets} + {_('Confirmed system user')}: {meta.get('confirmed_system_user', '')} + ''' @action(detail=True, methods=[POST], permission_classes=[IsAssignee, IsValidUser]) def reject(self, request, *args, **kwargs): instance = self.get_object() - action = instance.ACTION_REJECT + action = instance.ACTION.REJECT self._check_can_set_action(instance, action) - instance.perform_action(action, request.user) + instance.perform_action(action, request.user, self._get_extra_comment(instance)) return Response() @action(detail=True, methods=[POST], permission_classes=[IsAssignee, IsValidUser]) def approve(self, request, *args, **kwargs): instance = self.get_object() - action = instance.ACTION_APPROVE + action = instance.ACTION.APPROVE self._check_can_set_action(instance, action) meta = instance.meta @@ -115,23 +106,27 @@ class RequestAssetPermTicketViewSet(JMSModelViewSet): def _create_asset_permission(self, instance: Ticket, assets, system_user): meta = instance.meta request = self.request + actions = meta.get('actions', Action.CONNECT) + ap_kwargs = { - 'name': meta.get('name', ''), + 'name': _('From request ticket: {} {}').format(instance.user_display, instance.id), 'created_by': self.request.user.username, 'comment': _('{} request assets, approved by {}').format(instance.user_display, - instance.assignee_display) + instance.assignees_display), + 'actions': actions, } - date_start = meta.get('date_start') - date_expired = meta.get('date_expired') + date_start = dt_parser(meta.get('date_start')) + date_expired = dt_parser(meta.get('date_expired')) if date_start: ap_kwargs['date_start'] = date_start if date_expired: ap_kwargs['date_expired'] = date_expired - - with atomic(): - instance.perform_action(instance.ACTION_APPROVE, request.user) - ap = AssetPermission.objects.create(**ap_kwargs) - ap.system_users.add(system_user) - ap.assets.add(*assets) + instance.perform_action(instance.ACTION.APPROVE, + request.user, + self._get_extra_comment(instance)) + ap = AssetPermission.objects.create(**ap_kwargs) + ap.system_users.add(system_user) + ap.assets.add(*assets) + ap.users.add(instance.user) return ap diff --git a/apps/tickets/api/ticket.py b/apps/tickets/api/ticket.py index 5e3d90701..5a49c746d 100644 --- a/apps/tickets/api/ticket.py +++ b/apps/tickets/api/ticket.py @@ -11,7 +11,7 @@ from .. import serializers, models, mixins class TicketViewSet(mixins.TicketMixin, viewsets.ModelViewSet): serializer_class = serializers.TicketSerializer - queryset = models.Ticket.objects.all() + queryset = models.Ticket.origin_objects.all() permission_classes = (IsValidUser,) filter_fields = ['status', 'title', 'action', 'user_display'] search_fields = ['user_display', 'title'] diff --git a/apps/tickets/exceptions.py b/apps/tickets/exceptions.py index b8cb7ba5e..5e5dedd21 100644 --- a/apps/tickets/exceptions.py +++ b/apps/tickets/exceptions.py @@ -1,3 +1,5 @@ +from django.utils.translation import gettext_lazy as _ + from common.exceptions import JMSException @@ -18,8 +20,19 @@ class ConfirmedSystemUserChanged(JMSException): class TicketClosed(JMSException): + default_detail = _('Ticket closed') + default_code = 'ticket_closed' + + +class TicketActionAlready(JMSException): pass -class TicketActionYet(JMSException): - pass +class OnlyTicketAssigneeCanOperate(JMSException): + default_detail = _('Only assignee can operate ticket') + default_code = 'can_not_operate' + + +class TicketCanNotOperate(JMSException): + default_detail = _('Ticket can not be operated') + default_code = 'ticket_can_not_be_operated' diff --git a/apps/tickets/migrations/0002_auto_20200728_1146.py b/apps/tickets/migrations/0002_auto_20200728_1146.py new file mode 100644 index 000000000..303395144 --- /dev/null +++ b/apps/tickets/migrations/0002_auto_20200728_1146.py @@ -0,0 +1,30 @@ +# Generated by Django 2.2.10 on 2020-07-28 03:46 + +from django.db import migrations, models +import django.db.models.manager + + +class Migration(migrations.Migration): + + dependencies = [ + ('tickets', '0001_initial'), + ] + + operations = [ + migrations.AlterModelManagers( + name='ticket', + managers=[ + ('origin_objects', django.db.models.manager.Manager()), + ], + ), + migrations.AddField( + model_name='ticket', + name='org_id', + field=models.CharField(blank=True, db_index=True, default='', max_length=36, verbose_name='Organization'), + ), + migrations.AlterField( + model_name='ticket', + name='type', + field=models.CharField(choices=[('general', 'General'), ('login_confirm', 'Login confirm'), ('request_asset', 'Request asset permission')], default='general', max_length=16, verbose_name='Type'), + ), + ] diff --git a/apps/tickets/migrations/0003_auto_20200804_1551.py b/apps/tickets/migrations/0003_auto_20200804_1551.py new file mode 100644 index 000000000..936dbc5bb --- /dev/null +++ b/apps/tickets/migrations/0003_auto_20200804_1551.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.10 on 2020-08-04 07:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tickets', '0002_auto_20200728_1146'), + ] + + operations = [ + migrations.AlterField( + model_name='ticket', + name='assignee_display', + field=models.CharField(blank=True, default='', max_length=128, null=True, verbose_name='Assignee display name'), + ), + ] diff --git a/apps/tickets/mixins.py b/apps/tickets/mixins.py index 6f052df66..c4a48d866 100644 --- a/apps/tickets/mixins.py +++ b/apps/tickets/mixins.py @@ -6,11 +6,12 @@ from .models import Ticket class TicketMixin: def get_queryset(self): + queryset = super().get_queryset() assign = self.request.GET.get('assign', None) if assign is None: - queryset = Ticket.get_related_tickets(self.request.user) + queryset = Ticket.get_related_tickets(self.request.user, queryset) elif assign in ['1']: - queryset = Ticket.get_assigned_tickets(self.request.user) + queryset = Ticket.get_assigned_tickets(self.request.user, queryset) else: - queryset = Ticket.get_my_tickets(self.request.user) + queryset = Ticket.get_my_tickets(self.request.user, queryset) return queryset diff --git a/apps/tickets/models/ticket.py b/apps/tickets/models/ticket.py index 631761069..3e979f244 100644 --- a/apps/tickets/models/ticket.py +++ b/apps/tickets/models/ticket.py @@ -5,33 +5,28 @@ from django.db import models from django.db.models import Q from django.utils.translation import ugettext_lazy as _ +from common.db.models import ChoiceSet from common.mixins.models import CommonModelMixin from common.fields.model import JsonDictTextField +from orgs.mixins.models import OrgModelMixin __all__ = ['Ticket', 'Comment'] -class Ticket(CommonModelMixin): - STATUS_OPEN = 'open' - STATUS_CLOSED = 'closed' - STATUS_CHOICES = ( - (STATUS_OPEN, _("Open")), - (STATUS_CLOSED, _("Closed")) - ) - TYPE_GENERAL = 'general' - TYPE_LOGIN_CONFIRM = 'login_confirm' - TYPE_REQUEST_ASSET_PERM = 'request_asset' - TYPE_CHOICES = ( - (TYPE_GENERAL, _("General")), - (TYPE_LOGIN_CONFIRM, _("Login confirm")), - (TYPE_REQUEST_ASSET_PERM, _('Request asset permission')) - ) - ACTION_APPROVE = 'approve' - ACTION_REJECT = 'reject' - ACTION_CHOICES = ( - (ACTION_APPROVE, _('Approve')), - (ACTION_REJECT, _('Reject')), - ) +class Ticket(OrgModelMixin, CommonModelMixin): + class STATUS(ChoiceSet): + OPEN = 'open', _("Open") + CLOSED = 'closed', _("Closed") + + class TYPE(ChoiceSet): + GENERAL = 'general', _("General") + LOGIN_CONFIRM = 'login_confirm', _("Login confirm") + REQUEST_ASSET_PERM = 'request_asset', _('Request asset permission') + + class ACTION(ChoiceSet): + APPROVE = 'approve', _('Approve') + REJECT = 'reject', _('Reject') + user = models.ForeignKey('users.User', on_delete=models.SET_NULL, null=True, related_name='%(class)s_requested', verbose_name=_("User")) user_display = models.CharField(max_length=128, verbose_name=_("User display name")) @@ -39,12 +34,14 @@ class Ticket(CommonModelMixin): body = models.TextField(verbose_name=_("Body")) meta = JsonDictTextField(verbose_name=_("Meta"), default='{}') assignee = models.ForeignKey('users.User', on_delete=models.SET_NULL, null=True, related_name='%(class)s_handled', verbose_name=_("Assignee")) - assignee_display = models.CharField(max_length=128, blank=True, null=True, verbose_name=_("Assignee display name")) + assignee_display = models.CharField(max_length=128, blank=True, null=True, verbose_name=_("Assignee display name"), default='') assignees = models.ManyToManyField('users.User', related_name='%(class)s_assigned', verbose_name=_("Assignees")) assignees_display = models.CharField(max_length=128, verbose_name=_("Assignees display name"), blank=True) - type = models.CharField(max_length=16, choices=TYPE_CHOICES, default=TYPE_GENERAL, verbose_name=_("Type")) - status = models.CharField(choices=STATUS_CHOICES, max_length=16, default='open') - action = models.CharField(choices=ACTION_CHOICES, max_length=16, default='', blank=True) + type = models.CharField(max_length=16, choices=TYPE.choices, default=TYPE.GENERAL, verbose_name=_("Type")) + status = models.CharField(choices=STATUS.choices, max_length=16, default='open') + action = models.CharField(choices=ACTION.choices, max_length=16, default='', blank=True) + + origin_objects = models.Manager() def __str__(self): return '{}: {}'.format(self.user_display, self.title) @@ -66,28 +63,38 @@ class Ticket(CommonModelMixin): return self.get_action_display() def create_status_comment(self, status, user): - if status == self.STATUS_CLOSED: + if status == self.STATUS.CLOSED: action = _("Close") else: action = _("Open") body = _('{} {} this ticket').format(self.user, action) self.comments.create(user=user, body=body) - def perform_status(self, status, user): - if self.status == status: - return + def perform_status(self, status, user, extra_comment=None): + self.create_comment( + self.STATUS.get(status), + user, + extra_comment + ) self.status = status + self.assignee = user + self.assignees_display = str(user) self.save() - def create_action_comment(self, action, user): - action_display = dict(self.ACTION_CHOICES).get(action) + def create_comment(self, action_display, user, extra_comment=None): body = '{} {} {}'.format(user, action_display, _("this ticket")) + if extra_comment is not None: + body += extra_comment self.comments.create(body=body, user=user, user_display=str(user)) - def perform_action(self, action, user): - self.create_action_comment(action, user) + def perform_action(self, action, user, extra_comment=None): + self.create_comment( + self.ACTION.get(action), + user, + extra_comment + ) self.action = action - self.status = self.STATUS_CLOSED + self.status = self.STATUS.CLOSED self.assignee = user self.assignees_display = str(user) self.save() diff --git a/apps/tickets/serializers/request_asset_perm.py b/apps/tickets/serializers/request_asset_perm.py index 54e5ed79c..8827f482c 100644 --- a/apps/tickets/serializers/request_asset_perm.py +++ b/apps/tickets/serializers/request_asset_perm.py @@ -1,16 +1,27 @@ +from itertools import chain + from rest_framework import serializers +from django.conf import settings from django.utils.translation import ugettext_lazy as _ from django.urls import reverse from django.db.models import Q +from common.utils.timezone import dt_parser, dt_formater +from orgs.utils import tmp_to_root_org +from orgs.models import Organization, ROLE as ORG_ROLE +from assets.models.asset import Asset from users.models.user import User +from perms.serializers import ActionsField +from perms.models import Action from ..models import Ticket class RequestAssetPermTicketSerializer(serializers.ModelSerializer): + actions = ActionsField(source='meta.actions', choices=Action.DB_CHOICES, + default=Action.CONNECT) ips = serializers.ListField(child=serializers.IPAddressField(), source='meta.ips', default=list, label=_('IP group')) - hostname = serializers.CharField(max_length=256, source='meta.hostname', default=None, + hostname = serializers.CharField(max_length=256, source='meta.hostname', default='', allow_blank=True, label=_('Hostname')) system_user = serializers.CharField(max_length=256, source='meta.system_user', default='', allow_blank=True, label=_('System user')) @@ -22,9 +33,8 @@ class RequestAssetPermTicketSerializer(serializers.ModelSerializer): source='meta.confirmed_assets', default=list, required=False, label=_('Confirmed assets')) - confirmed_system_user = serializers.ListField(child=serializers.UUIDField(), - source='meta.confirmed_system_user', - default=list, required=False, + confirmed_system_user = serializers.UUIDField(source='meta.confirmed_system_user', + default='', required=False, label=_('Confirmed system user')) assets_waitlist_url = serializers.SerializerMethodField() system_user_waitlist_url = serializers.SerializerMethodField() @@ -36,7 +46,7 @@ class RequestAssetPermTicketSerializer(serializers.ModelSerializer): 'status', 'action', 'date_created', 'date_updated', 'system_user_waitlist_url', 'type', 'type_display', 'action_display', 'ips', 'confirmed_assets', 'date_start', 'date_expired', 'confirmed_system_user', 'hostname', - 'assets_waitlist_url', 'system_user' + 'assets_waitlist_url', 'system_user', 'org_id', 'actions' ] m2m_fields = [ 'user', 'user_display', 'assignees', 'assignees_display', @@ -52,26 +62,44 @@ class RequestAssetPermTicketSerializer(serializers.ModelSerializer): extra_kwargs = { 'status': {'label': _('Status')}, 'action': {'label': _('Action')}, - 'user_display': {'label': _('User')} + 'user_display': {'label': _('User')}, + 'org_id': {'required': True} } - def validate_assignees(self, assignees): + def validate(self, attrs): + org_id = attrs.get('org_id') + assignees = attrs.get('assignees') + + instance = self.instance + if instance is not None: + if org_id and not assignees: + assignees = list(instance.assignees.all()) + elif assignees and not org_id: + org_id = instance.org_id + elif assignees and org_id: + pass + else: + return attrs + user = self.context['request'].user + org = Organization.get_instance(org_id) + if org is None: + raise serializers.ValidationError(_('Invalid `org_id`')) - count = User.objects.filter(Q(related_admin_orgs__users=user) | Q(role=User.ROLE_ADMIN)).filter( - id__in=[assignee.id for assignee in assignees]).distinct().count() + q = Q(role=User.ROLE.ADMIN) + if not org.is_default(): + q |= Q(m2m_org_members__role=ORG_ROLE.ADMIN, orgs__id=org_id, orgs__members=user) + q &= Q(id__in=[assignee.id for assignee in assignees]) + count = User.objects.filter(q).distinct().count() if count != len(assignees): - raise serializers.ValidationError(_('Must be organization admin or superuser')) - return assignees + raise serializers.ValidationError(_('Field `assignees` must be organization admin or superuser')) + return attrs def get_system_user_waitlist_url(self, instance: Ticket): if not self._is_assignee(instance): return None - meta = instance.meta - url = reverse('api-assets:system-user-list') - query = meta.get('system_user', '') - return '{}?search={}'.format(url, query) + return reverse('api-assets:system-user-list') def get_assets_waitlist_url(self, instance: Ticket): if not self._is_assignee(instance): @@ -81,37 +109,106 @@ class RequestAssetPermTicketSerializer(serializers.ModelSerializer): query = '' meta = instance.meta - ips = meta.get('ips', []) hostname = meta.get('hostname') - - if ips: - query = '?ips=%s' % ','.join(ips) - elif hostname: + if hostname: query = '?search=%s' % hostname return asset_api + query + def _recommend_assets(self, data, instance): + confirmed_assets = data.get('confirmed_assets') + if not confirmed_assets and self._is_assignee(instance): + ips = data.get('ips') + hostname = data.get('hostname') + limit = 5 + + q = Q(id=None) + if ips: + limit = len(ips) + 2 + q |= Q(ip__in=ips) + if hostname: + q |= Q(hostname__icontains=hostname) + + data['confirmed_assets'] = list( + map(lambda x: str(x), chain(*Asset.objects.filter(q)[0: limit].values_list('id')))) + + def to_representation(self, instance): + data = super().to_representation(instance) + self._recommend_assets(data, instance) + return data + + def _create_body(self, validated_data): + meta = validated_data['meta'] + type = Ticket.TYPE.get(validated_data.get('type', '')) + date_start = dt_parser(meta.get('date_start')).strftime(settings.DATETIME_DISPLAY_FORMAT) + date_expired = dt_parser(meta.get('date_expired')).strftime(settings.DATETIME_DISPLAY_FORMAT) + + validated_data['body'] = _(''' + Type: {type}
+ User: {username}
+ Ip group: {ips}
+ Hostname: {hostname}
+ System user: {system_user}
+ Date start: {date_start}
+ Date expired: {date_expired}
+ ''').format( + type=type, + username=validated_data.get('user', ''), + ips=', '.join(meta.get('ips', [])), + hostname=meta.get('hostname', ''), + system_user=meta.get('system_user', ''), + date_start=date_start, + date_expired=date_expired + ) + def create(self, validated_data): - validated_data['type'] = self.Meta.model.TYPE_REQUEST_ASSET_PERM + # `type` 与 `user` 用户不可提交, + validated_data['type'] = self.Meta.model.TYPE.REQUEST_ASSET_PERM validated_data['user'] = self.context['request'].user + # `confirmed` 相关字段只能审批人修改,所以创建时直接清理掉 self._pop_confirmed_fields() + self._create_body(validated_data) return super().create(validated_data) def save(self, **kwargs): + """ + 做了一些数据转换 + """ meta = self.validated_data.get('meta', {}) + + org_id = self.validated_data.get('org_id') + if org_id is not None and org_id == Organization.DEFAULT_ID: + self.validated_data['org_id'] = '' + + # 时间的转换,好烦😭,可能有更好的办法吧 date_start = meta.get('date_start') if date_start: - meta['date_start'] = date_start.strftime('%Y-%m-%d %H:%M:%S%z') + meta['date_start'] = dt_formater(date_start) date_expired = meta.get('date_expired') if date_expired: - meta['date_expired'] = date_expired.strftime('%Y-%m-%d %H:%M:%S%z') - return super().save(**kwargs) + meta['date_expired'] = dt_formater(date_expired) + + # UUID 的转换 + confirmed_system_user = meta.get('confirmed_system_user') + if confirmed_system_user: + meta['confirmed_system_user'] = str(confirmed_system_user) + + confirmed_assets = meta.get('confirmed_assets') + if confirmed_assets: + new_confirmed_assets = [] + for asset in confirmed_assets: + new_confirmed_assets.append(str(asset)) + meta['confirmed_assets'] = new_confirmed_assets + with tmp_to_root_org(): + return super().save(**kwargs) def update(self, instance, validated_data): new_meta = validated_data['meta'] if not self._is_assignee(instance): self._pop_confirmed_fields() + + # Json 字段保存的坑😭 old_meta = instance.meta meta = {} meta.update(old_meta) @@ -134,8 +231,3 @@ class AssigneeSerializer(serializers.Serializer): id = serializers.UUIDField() name = serializers.CharField() username = serializers.CharField() - - -class OrgAssigneeSerializer(serializers.Serializer): - org_name = serializers.CharField() - org_admins = AssigneeSerializer(many=True) diff --git a/apps/tickets/serializers/ticket.py b/apps/tickets/serializers/ticket.py index f6c995ae0..34724be3a 100644 --- a/apps/tickets/serializers/ticket.py +++ b/apps/tickets/serializers/ticket.py @@ -3,14 +3,18 @@ from django.utils.translation import ugettext_lazy as _ from rest_framework import serializers -from .. import models +from ..exceptions import ( + TicketClosed, OnlyTicketAssigneeCanOperate, + TicketCanNotOperate +) +from ..models import Ticket, Comment __all__ = ['TicketSerializer', 'CommentSerializer'] class TicketSerializer(serializers.ModelSerializer): class Meta: - model = models.Ticket + model = Ticket fields = [ 'id', 'user', 'user_display', 'title', 'body', 'assignees', 'assignees_display', 'assignee', 'assignee_display', @@ -32,17 +36,33 @@ class TicketSerializer(serializers.ModelSerializer): return super().create(validated_data) def update(self, instance, validated_data): - action = validated_data.get("action") - user = self.context["request"].user + action = validated_data.get('action') + user = self.context['request'].user + + if instance.type not in (Ticket.TYPE.GENERAL, + Ticket.TYPE.LOGIN_CONFIRM): + # 暂时的兼容操作吧,后期重构工单 + raise TicketCanNotOperate + + if instance.status == instance.STATUS.CLOSED: + raise TicketClosed + + if action: + if user not in instance.assignees.all(): + raise OnlyTicketAssigneeCanOperate + + # 有 `action` 时忽略 `status` + validated_data.pop('status', None) + + instance = super().update(instance, validated_data) + if not instance.status == instance.STATUS.CLOSED and action: + instance.perform_action(action, user) + else: + status = validated_data.get('status') + instance = super().update(instance, validated_data) + if status: + instance.perform_status(status, user) - if action and user not in instance.assignees.all(): - error = {"action": "Only assignees can update"} - raise serializers.ValidationError(error) - if instance.status == instance.STATUS_CLOSED: - validated_data.pop('action') - instance = super().update(instance, validated_data) - if not instance.status == instance.STATUS_CLOSED and action: - instance.perform_action(action, user) return instance @@ -65,7 +85,7 @@ class CommentSerializer(serializers.ModelSerializer): ) class Meta: - model = models.Comment + model = Comment fields = [ 'id', 'ticket', 'body', 'user', 'user_display', 'date_created', 'date_updated' diff --git a/apps/tickets/tests.py b/apps/tickets/tests.py index 7ce503c2d..2b02a9016 100644 --- a/apps/tickets/tests.py +++ b/apps/tickets/tests.py @@ -1,3 +1,89 @@ -from django.test import TestCase +import datetime -# Create your tests here. +from common.utils.timezone import now +from django.urls import reverse +from rest_framework.test import APITestCase +from rest_framework import status + +from orgs.models import Organization, OrganizationMember, ROLE as ORG_ROLE +from orgs.utils import set_current_org +from users.models.user import User +from assets.models import Asset, AdminUser, SystemUser + + +class TicketTest(APITestCase): + def setUp(self): + Organization.objects.bulk_create([ + Organization(name='org-01'), + Organization(name='org-02'), + Organization(name='org-03'), + ]) + org_01, org_02, org_03 = Organization.objects.all() + self.org_01, self.org_02, self.org_03 = org_01, org_02, org_03 + + set_current_org(org_01) + + AdminUser.objects.bulk_create([ + AdminUser(name='au-01', username='au-01'), + AdminUser(name='au-02', username='au-02'), + AdminUser(name='au-03', username='au-03'), + ]) + + SystemUser.objects.bulk_create([ + SystemUser(name='su-01', username='su-01'), + SystemUser(name='su-02', username='su-02'), + SystemUser(name='su-03', username='su-03'), + ]) + + admin_users = AdminUser.objects.all() + Asset.objects.bulk_create([ + Asset(hostname='asset-01', ip='192.168.1.1', public_ip='192.168.1.1', admin_user=admin_users[0]), + Asset(hostname='asset-02', ip='192.168.1.2', public_ip='192.168.1.2', admin_user=admin_users[0]), + Asset(hostname='asset-03', ip='192.168.1.3', public_ip='192.168.1.3', admin_user=admin_users[0]), + ]) + + new_user = User.objects.create + new_org_memeber = OrganizationMember.objects.create + + u = new_user(name='user-01', username='user-01', email='user-01@jms.com') + new_org_memeber(org=org_01, user=u, role=ORG_ROLE.USER) + new_org_memeber(org=org_02, user=u, role=ORG_ROLE.USER) + self.user_01 = u + + u = new_user(name='org-admin-01', username='org-admin-01', email='org-admin-01@jms.com') + new_org_memeber(org=org_01, user=u, role=ORG_ROLE.ADMIN) + self.org_admin_01 = u + + u = new_user(name='org-admin-02', username='org-admin-02', email='org-admin-02@jms.com') + new_org_memeber(org=org_02, user=u, role=ORG_ROLE.ADMIN) + self.org_admin_02 = u + + def test_create_request_asset_perm(self): + url = reverse('api-tickets:ticket-request-asset-perm') + ticket_url = reverse('api-tickets:ticket') + + self.client.force_login(self.user_01) + + date_start = now() + date_expired = date_start + datetime.timedelta(days=7) + + data = { + "title": "request-01", + "ips": [ + "192.168.1.1" + ], + "date_start": date_start, + "date_expired": date_expired, + "hostname": "", + "system_user": "", + "org_id": self.org_01.id, + "assignees": [ + str(self.org_admin_01.id), + str(self.org_admin_02.id), + ] + } + + self.client.post(data) + + self.client.force_login(self.org_admin_01) + res = self.client.get(ticket_url, params={'assgin': 1}) diff --git a/apps/tickets/urls/api_urls.py b/apps/tickets/urls/api_urls.py index b086aa9d3..a7bd3f6e5 100644 --- a/apps/tickets/urls/api_urls.py +++ b/apps/tickets/urls/api_urls.py @@ -7,7 +7,7 @@ from .. import api app_name = 'tickets' router = BulkRouter() -# router.register('tickets/request-asset-perm', api.RequestAssetPermTicketViewSet, 'ticket-request-asset-perm') +router.register('tickets/request-asset-perm', api.RequestAssetPermTicketViewSet, 'ticket-request-asset-perm') router.register('tickets', api.TicketViewSet, 'ticket') router.register('tickets/(?P[0-9a-zA-Z\-]{36})/comments', api.TicketCommentViewSet, 'ticket-comment') diff --git a/apps/tickets/utils.py b/apps/tickets/utils.py index 13727a77d..152b5182b 100644 --- a/apps/tickets/utils.py +++ b/apps/tickets/utils.py @@ -1,23 +1,30 @@ # -*- coding: utf-8 -*- # +from urllib.parse import urljoin from django.conf import settings from django.utils.translation import ugettext as _ -from common.utils import get_logger, reverse +from common.utils import get_logger from common.tasks import send_mail_async logger = get_logger(__name__) +from tickets.models import Ticket -def send_new_ticket_mail_to_assignees(ticket, assignees): +def send_new_ticket_mail_to_assignees(ticket: Ticket, assignees): recipient_list = [user.email for user in assignees] user = ticket.user if not recipient_list: logger.error("Ticket not has assignees: {}".format(ticket.id)) return subject = '{}: {}'.format(_("New ticket"), ticket.title) - detail_url = reverse('tickets:ticket-detail', - kwargs={'pk': ticket.id}, external=True) + + # 这里要设置前端地址,因为要直接跳转到页面 + if ticket.type == ticket.TYPE.REQUEST_ASSET_PERM: + detail_url = urljoin(settings.SITE_URL, f'/tickets/tickets/request-asset-perm/{ticket.id}') + else: + detail_url = urljoin(settings.SITE_URL, f'/tickets/tickets/{ticket.id}') + message = _("""

Your has a new ticket

diff --git a/apps/users/api/mixins.py b/apps/users/api/mixins.py index 117c2c28a..0e81bd8d2 100644 --- a/apps/users/api/mixins.py +++ b/apps/users/api/mixins.py @@ -9,7 +9,7 @@ from orgs.utils import current_org class UserQuerysetMixin: def get_queryset(self): if self.request.query_params.get('all') or not current_org.is_real(): - queryset = User.objects.exclude(role=User.ROLE_APP) + queryset = User.objects.exclude(role=User.ROLE.APP) else: queryset = utils.get_current_org_members() return queryset diff --git a/apps/users/api/relation.py b/apps/users/api/relation.py index fbab92ee9..218d52142 100644 --- a/apps/users/api/relation.py +++ b/apps/users/api/relation.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- # -from rest_framework_bulk import BulkModelViewSet from django.db.models import F +from common.drf.api import JMSBulkRelationModelViewSet from common.permissions import IsOrgAdmin from .. import serializers from ..models import User @@ -11,17 +11,17 @@ from ..models import User __all__ = ['UserUserGroupRelationViewSet'] -class UserUserGroupRelationViewSet(BulkModelViewSet): +class UserUserGroupRelationViewSet(JMSBulkRelationModelViewSet): filter_fields = ('user', 'usergroup') search_fields = filter_fields serializer_class = serializers.UserUserGroupRelationSerializer permission_classes = (IsOrgAdmin,) + m2m_field = User.groups.field def get_queryset(self): - queryset = User.groups.through.objects.all()\ - .annotate(user_display=F('user__name'))\ - .annotate(usergroup_display=F('usergroup__name')) - return queryset + return super().get_queryset().annotate( + user_display=F('user__name'), usergroup_display=F('usergroup__name') + ) def allow_bulk_destroy(self, qs, filtered): if filtered.count() != 1: diff --git a/apps/users/api/user.py b/apps/users/api/user.py index 3ad748316..b1c039318 100644 --- a/apps/users/api/user.py +++ b/apps/users/api/user.py @@ -1,11 +1,13 @@ # ~*~ coding: utf-8 ~*~ from django.core.cache import cache +from django.db.models import CharField from django.utils.translation import ugettext as _ from rest_framework import generics from rest_framework.response import Response from rest_framework_bulk import BulkModelViewSet +from common.db.aggregates import GroupConcat from common.permissions import ( IsOrgAdmin, IsOrgAdminOrAppUser, CanUpdateDeleteUser, IsSuperUser @@ -13,6 +15,7 @@ from common.permissions import ( from common.mixins import CommonApiMixin from common.utils import get_logger from orgs.utils import current_org +from orgs.models import ROLE as ORG_ROLE, OrganizationMember from .. import serializers from ..serializers import UserSerializer, UserRetrieveSerializer from .mixins import UserQuerysetMixin @@ -39,7 +42,11 @@ class UserViewSet(CommonApiMixin, UserQuerysetMixin, BulkModelViewSet): extra_filter_backends = [OrgRoleUserFilterBackend] def get_queryset(self): - return super().get_queryset().prefetch_related('groups') + return super().get_queryset().annotate( + gc_m2m_org_members__role=GroupConcat('m2m_org_members__role'), + gc_groups__name=GroupConcat('groups__name'), + gc_groups=GroupConcat('groups__id', output_field=CharField()) + ) def send_created_signal(self, users): if not isinstance(users, list): @@ -48,11 +55,32 @@ class UserViewSet(CommonApiMixin, UserQuerysetMixin, BulkModelViewSet): post_user_create.send(self.__class__, user=user) def perform_create(self, serializer): + validated_data = serializer.validated_data + if isinstance(validated_data, list): + org_roles = [item.pop('org_role', None) for item in validated_data] + else: + org_roles = [validated_data.pop('org_role', None)] + users = serializer.save() if isinstance(users, User): users = [users] if current_org and current_org.is_real(): - current_org.users.add(*users) + mapper = { + ORG_ROLE.USER: [], + ORG_ROLE.ADMIN: [], + ORG_ROLE.AUDITOR: [] + } + + for user, role in zip(users, org_roles): + if role in mapper: + mapper[role].append(user) + else: + mapper[ORG_ROLE.USER].append(user) + OrganizationMember.objects.set_users_by_role( + current_org, users=mapper[ORG_ROLE.USER], + admins=mapper[ORG_ROLE.ADMIN], + auditors=mapper[ORG_ROLE.AUDITOR] + ) self.send_created_signal(users) def get_permissions(self): @@ -78,7 +106,7 @@ class UserViewSet(CommonApiMixin, UserQuerysetMixin, BulkModelViewSet): users_ids = [ d.get("id") or d.get("pk") for d in serializer.validated_data ] - users = current_org.get_org_members().filter(id__in=users_ids) + users = current_org.get_members().filter(id__in=users_ids) for user in users: self.check_object_permissions(self.request, user) return super().perform_bulk_update(serializer) diff --git a/apps/users/filters.py b/apps/users/filters.py index d12d3234e..faf5959c8 100644 --- a/apps/users/filters.py +++ b/apps/users/filters.py @@ -12,13 +12,13 @@ class OrgRoleUserFilterBackend(filters.BaseFilterBackend): return queryset if org_role == 'admins': - return queryset & (current_org.get_org_admins() | User.objects.filter(role=User.ROLE_ADMIN)) + return queryset & (current_org.admins | User.objects.filter(role=User.ROLE.ADMIN)) elif org_role == 'auditors': - return queryset & current_org.get_org_auditors() + return queryset & current_org.auditors elif org_role == 'users': - return queryset & current_org.get_org_users() + return queryset & current_org.users elif org_role == 'members': - return queryset & current_org.get_org_members() + return queryset & current_org.get_members() def get_schema_fields(self, view): return [ diff --git a/apps/users/forms/user.py b/apps/users/forms/user.py index a58e1fef1..f3852c0dd 100644 --- a/apps/users/forms/user.py +++ b/apps/users/forms/user.py @@ -17,14 +17,14 @@ __all__ = [ class UserCreateUpdateFormMixin(OrgModelForm): - role_choices = ((i, n) for i, n in User.ROLE_CHOICES if i != User.ROLE_APP) + role_choices = ((i, n) for i, n in User.ROLE.choices if i != User.ROLE.APP) password = forms.CharField( label=_('Password'), widget=forms.PasswordInput, max_length=128, strip=False, required=False, ) role = forms.ChoiceField( choices=role_choices, required=True, - initial=User.ROLE_USER, label=_("Role") + initial=User.ROLE.USER, label=_("Role") ) source = forms.ChoiceField( choices=get_source_choices, required=True, @@ -60,9 +60,9 @@ class UserCreateUpdateFormMixin(OrgModelForm): roles = [] # Super admin user if self.request.user.is_superuser: - roles.append((User.ROLE_ADMIN, dict(User.ROLE_CHOICES).get(User.ROLE_ADMIN))) - roles.append((User.ROLE_USER, dict(User.ROLE_CHOICES).get(User.ROLE_USER))) - roles.append((User.ROLE_AUDITOR, dict(User.ROLE_CHOICES).get(User.ROLE_AUDITOR))) + roles.append((User.ROLE.ADMIN, User.ROLE.ADMIN.label)) + roles.append((User.ROLE.USER, User.ROLE.USER.label)) + roles.append((User.ROLE.AUDITOR, User.ROLE.AUDITOR.label)) # Org admin user else: @@ -70,10 +70,10 @@ class UserCreateUpdateFormMixin(OrgModelForm): # Update if user: role = kwargs.get('instance').role - roles.append((role, dict(User.ROLE_CHOICES).get(role))) + roles.append((role, User.ROLE[role])) # Create else: - roles.append((User.ROLE_USER, dict(User.ROLE_CHOICES).get(User.ROLE_USER))) + roles.append((User.ROLE.USER, User.ROLE.USER.label)) field = self.fields['role'] field.choices = set(roles) diff --git a/apps/users/migrations/0028_auto_20200728_1805.py b/apps/users/migrations/0028_auto_20200728_1805.py new file mode 100644 index 000000000..6e57d04a6 --- /dev/null +++ b/apps/users/migrations/0028_auto_20200728_1805.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.10 on 2020-07-28 10:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0027_auto_20200616_1503'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='role', + field=models.CharField(blank=True, choices=[('Admin', 'Super administrator'), ('User', 'User'), ('Auditor', 'Super auditor'), ('App', 'Application')], default='User', max_length=10, verbose_name='Role'), + ), + ] diff --git a/apps/users/models/user.py b/apps/users/models/user.py index 89bfe4254..6bda75b0a 100644 --- a/apps/users/models/user.py +++ b/apps/users/models/user.py @@ -18,8 +18,10 @@ from django.shortcuts import reverse from common.local import LOCAL_DYNAMIC_SETTINGS from orgs.utils import current_org -from common.utils import signer, date_expired_default, get_logger, lazyproperty +from common.utils import date_expired_default, get_logger, lazyproperty from common import fields +from common.const import choices +from common.db.models import ChoiceSet from ..signals import post_user_change_password @@ -150,45 +152,58 @@ class AuthMixin: class RoleMixin: - ROLE_ADMIN = 'Admin' - ROLE_USER = 'User' - ROLE_APP = 'App' - ROLE_AUDITOR = 'Auditor' + class ROLE(ChoiceSet): + ADMIN = choices.ADMIN, _('Super administrator') + USER = choices.USER, _('User') + AUDITOR = choices.AUDITOR, _('Super auditor') + APP = 'App', _('Application') - ROLE_CHOICES = ( - (ROLE_ADMIN, _('Administrator')), - (ROLE_USER, _('User')), - (ROLE_APP, _('Application')), - (ROLE_AUDITOR, _("Auditor")) - ) - role = ROLE_USER + role = ROLE.USER @property def role_display(self): + return self.get_role_display() + + @property + def org_role_display(self): + from orgs.models import ROLE as ORG_ROLE + if not current_org.is_real(): - return self.get_role_display() - roles = [] - if self in current_org.get_org_admins(): - roles.append(str(_('Org admin'))) - if self in current_org.get_org_auditors(): - roles.append(str(_('Org auditor'))) - if self in current_org.get_org_users(): - roles.append(str(_('User'))) - return " | ".join(roles) + if self.is_superuser: + return ORG_ROLE.ADMIN.label + else: + return ORG_ROLE.USER.label + + if hasattr(self, 'gc_m2m_org_members__role'): + names = self.gc_m2m_org_members__role + if isinstance(names, str): + roles = set(self.gc_m2m_org_members__role.split(',')) + else: + roles = set() + else: + roles = set(self.m2m_org_members.filter( + org_id=current_org.id + ).values_list('role', flat=True)) + + return ' | '.join([str(ORG_ROLE[role]) for role in roles if role in ORG_ROLE]) def current_org_roles(self): - roles = [] - if self.can_admin_current_org: - roles.append('Admin') - if self.can_audit_current_org: - roles.append('Auditor') - else: - roles.append('User') + from orgs.models import OrganizationMember, ROLE as ORG_ROLE + if not current_org.is_real(): + if self.is_superuser: + return [ORG_ROLE.ADMIN] + else: + return [ORG_ROLE.USER] + + roles = list(set(OrganizationMember.objects.filter( + org_id=current_org.id, user=self + ).values_list('role', flat=True))) + return roles @property def is_superuser(self): - if self.role == 'Admin': + if self.role == self.ROLE.ADMIN: return True else: return False @@ -196,13 +211,13 @@ class RoleMixin: @is_superuser.setter def is_superuser(self, value): if value is True: - self.role = 'Admin' + self.role = self.ROLE.ADMIN else: - self.role = 'User' + self.role = self.ROLE.USER @property def is_super_auditor(self): - return self.role == 'Auditor' + return self.role == self.ROLE.AUDITOR @property def is_common_user(self): @@ -216,7 +231,12 @@ class RoleMixin: @property def is_app(self): - return self.role == 'App' + return self.role == self.ROLE.APP + + @lazyproperty + def user_all_orgs(self): + from orgs.models import Organization + return Organization.get_user_all_orgs(self) @lazyproperty def user_orgs(self): @@ -240,14 +260,16 @@ class RoleMixin: @lazyproperty def is_org_admin(self): - if self.is_superuser or self.related_admin_orgs.exists(): + from orgs.models import ROLE as ORG_ROLE + if self.is_superuser or self.m2m_org_members.filter(role=ORG_ROLE.ADMIN).exists(): return True else: return False @lazyproperty def is_org_auditor(self): - if self.is_super_auditor or self.related_audit_orgs.exists(): + from orgs.models import ROLE as ORG_ROLE + if self.is_super_auditor or self.m2m_org_members.filter(role=ORG_ROLE.AUDITOR).exists(): return True else: return False @@ -283,7 +305,7 @@ class RoleMixin: def create_app_user(cls, name, comment): app = cls.objects.create( username=name, name=name, email='{}@local.domain'.format(name), - is_active=False, role='App', comment=comment, + is_active=False, role=cls.ROLE.APP, comment=comment, is_first_login=False, created_by='System' ) access_key = app.create_access_key() @@ -473,7 +495,7 @@ class User(AuthMixin, TokenMixin, RoleMixin, MFAMixin, AbstractUser): blank=True, verbose_name=_('User group') ) role = models.CharField( - choices=RoleMixin.ROLE_CHOICES, default='User', max_length=10, + choices=RoleMixin.ROLE.choices, default='User', max_length=10, blank=True, verbose_name=_('Role') ) avatar = models.ImageField( @@ -526,6 +548,12 @@ class User(AuthMixin, TokenMixin, RoleMixin, MFAMixin, AbstractUser): @property def groups_display(self): + if hasattr(self, 'gc_groups__name'): + names = self.gc_groups__name + if isinstance(names, str): + return ' '.join(set(self.gc_groups__name.split(','))) + else: + return '' return ' '.join([group.name for group in self.groups.all()]) @property @@ -646,7 +674,7 @@ class User(AuthMixin, TokenMixin, RoleMixin, MFAMixin, AbstractUser): email=forgery_py.internet.email_address(), name=forgery_py.name.full_name(), password=make_password(forgery_py.lorem_ipsum.word()), - role=choice(list(dict(User.ROLE_CHOICES).keys())), + role=choice(list(dict(User.ROLE.choices).keys())), wechat=forgery_py.internet.user_name(True), comment=forgery_py.lorem_ipsum.sentence(), created_by=choice(cls.objects.all()).username) diff --git a/apps/users/serializers/user.py b/apps/users/serializers/user.py index bebc5042b..908978d75 100644 --- a/apps/users/serializers/user.py +++ b/apps/users/serializers/user.py @@ -9,7 +9,9 @@ from common.utils import validate_ssh_public_key from common.mixins import CommonBulkSerializerMixin from common.serializers import AdaptedBulkListSerializer from common.permissions import CanUpdateDeleteUser -from ..models import User +from common.drf.fields import GroupConcatedPrimaryKeyRelatedField +from orgs.models import ROLE as ORG_ROLE +from ..models import User, UserGroup __all__ = [ @@ -17,7 +19,7 @@ __all__ = [ 'ChangeUserPasswordSerializer', 'ResetOTPSerializer', 'UserProfileSerializer', 'UserOrgSerializer', 'UserUpdatePasswordSerializer', 'UserUpdatePublicKeySerializer', - 'UserRetrieveSerializer' + 'UserRetrieveSerializer', 'MiniUserSerializer', ] @@ -26,6 +28,11 @@ class UserOrgSerializer(serializers.Serializer): name = serializers.CharField() +class UserOrgLabelSerializer(serializers.Serializer): + value = serializers.CharField(source='id') + label = serializers.CharField(source='name') + + class UserSerializer(CommonBulkSerializerMixin, serializers.ModelSerializer): EMAIL_SET_PASSWORD = _('Reset link will be generated and sent to the user') CUSTOM_PASSWORD = _('Set password') @@ -38,10 +45,18 @@ class UserSerializer(CommonBulkSerializerMixin, serializers.ModelSerializer): label=_('Password strategy'), write_only=True ) mfa_level_display = serializers.ReadOnlyField(source='get_mfa_level_display') + groups = GroupConcatedPrimaryKeyRelatedField( + label=_('User group'), many=True, queryset=UserGroup.objects.all(), required=False + ) login_blocked = serializers.SerializerMethodField() can_update = serializers.SerializerMethodField() can_delete = serializers.SerializerMethodField() - + org_role = serializers.ChoiceField( + label=_('Organization role name'), write_only=True, + allow_null=True, required=False, allow_blank=True, + choices=ORG_ROLE.choices + ) + total_role_display = serializers.SerializerMethodField(label=_('Total role name')) key_prefix_block = "_LOGIN_BLOCK_{}" class Meta: @@ -52,15 +67,15 @@ class UserSerializer(CommonBulkSerializerMixin, serializers.ModelSerializer): # small 指的是 不需要计算的直接能从一张表中获取到的数据 fields_small = fields_mini + [ 'password', 'email', 'public_key', 'wechat', 'phone', 'mfa_level', 'mfa_enabled', - 'mfa_level_display', 'mfa_force_enabled', - 'comment', 'source', 'is_valid', 'is_expired', + 'mfa_level_display', 'mfa_force_enabled', 'role_display', 'org_role_display', + 'total_role_display', 'comment', 'source', 'is_valid', 'is_expired', 'is_active', 'created_by', 'is_first_login', 'password_strategy', 'date_password_last_updated', 'date_expired', 'avatar_url', 'source_display', 'date_joined', 'last_login' ] fields = fields_small + [ 'groups', 'role', 'groups_display', 'role_display', - 'can_update', 'can_delete', 'login_blocked', + 'can_update', 'can_delete', 'login_blocked', 'org_role' ] extra_kwargs = { @@ -75,7 +90,8 @@ class UserSerializer(CommonBulkSerializerMixin, serializers.ModelSerializer): 'can_delete': {'read_only': True}, 'groups_display': {'label': _('Groups name')}, 'source_display': {'label': _('Source name')}, - 'role_display': {'label': _('Role name')}, + 'org_role_display': {'label': _('Organization role name')}, + 'role_display': {'label': _('Super role name')}, } def __init__(self, *args, **kwargs): @@ -87,17 +103,20 @@ class UserSerializer(CommonBulkSerializerMixin, serializers.ModelSerializer): if not role: return choices = role._choices - choices.pop(User.ROLE_APP, None) + choices.pop(User.ROLE.APP, None) request = self.context.get('request') if request and hasattr(request, 'user') and not request.user.is_superuser: - choices.pop(User.ROLE_ADMIN, None) - choices.pop(User.ROLE_AUDITOR, None) + choices.pop(User.ROLE.ADMIN, None) + choices.pop(User.ROLE.AUDITOR, None) role._choices = choices + def get_total_role_display(self, instance): + return ' | '.join({str(instance.role_display), str(instance.org_role_display)}) + def validate_role(self, value): request = self.context.get('request') - if not request.user.is_superuser and value != User.ROLE_USER: - role_display = dict(User.ROLE_CHOICES)[User.ROLE_USER] + if not request.user.is_superuser and value != User.ROLE.USER: + role_display = User.ROLE.USER.label msg = _("Role limit to {}".format(role_display)) raise serializers.ValidationError(msg) return value @@ -121,7 +140,7 @@ class UserSerializer(CommonBulkSerializerMixin, serializers.ModelSerializer): role = self.initial_data.get('role') if self.instance: role = role or self.instance.role - if role == User.ROLE_AUDITOR: + if role == User.ROLE.AUDITOR: return [] return groups @@ -206,6 +225,7 @@ class UserRoleSerializer(serializers.Serializer): class UserProfileSerializer(UserSerializer): admin_or_audit_orgs = UserOrgSerializer(many=True, read_only=True) + user_all_orgs = UserOrgLabelSerializer(many=True, read_only=True) current_org_roles = serializers.ListField(read_only=True) public_key_comment = serializers.CharField( source='get_public_key_comment', required=False, read_only=True, max_length=128 @@ -223,7 +243,7 @@ class UserProfileSerializer(UserSerializer): class Meta(UserSerializer.Meta): fields = UserSerializer.Meta.fields + [ 'public_key_comment', 'public_key_hash_md5', 'admin_or_audit_orgs', 'current_org_roles', - 'guide_url' + 'guide_url', 'user_all_orgs' ] extra_kwargs = dict(UserSerializer.Meta.extra_kwargs) extra_kwargs.update({ diff --git a/apps/users/serializers_v2/user.py b/apps/users/serializers_v2/user.py index 884c789af..d2b9cfa1c 100644 --- a/apps/users/serializers_v2/user.py +++ b/apps/users/serializers_v2/user.py @@ -39,7 +39,7 @@ class ServiceAccountSerializer(serializers.ModelSerializer): def save(self, **kwargs): self.validated_data['email'] = self.get_email() self.validated_data['username'] = self.get_username() - self.validated_data['role'] = User.ROLE_APP + self.validated_data['role'] = User.ROLE.APP return super().save(**kwargs) def create(self, validated_data): diff --git a/apps/users/tasks.py b/apps/users/tasks.py index 5c7bc0e47..f575a3afc 100644 --- a/apps/users/tasks.py +++ b/apps/users/tasks.py @@ -22,7 +22,7 @@ logger = get_logger(__file__) @shared_task def check_password_expired(): - users = User.objects.filter(source=User.SOURCE_LOCAL).exclude(role=User.ROLE_APP) + users = User.objects.filter(source=User.SOURCE_LOCAL).exclude(role=User.ROLE.APP) for user in users: if not user.is_valid: continue @@ -49,7 +49,7 @@ def check_password_expired_periodic(): @shared_task def check_user_expired(): - users = User.objects.exclude(role=User.ROLE_APP) + users = User.objects.exclude(role=User.ROLE.APP) for user in users: if not user.is_valid: continue diff --git a/apps/users/templates/users/user_detail.html b/apps/users/templates/users/user_detail.html index e9bdf8b92..0b624b78a 100644 --- a/apps/users/templates/users/user_detail.html +++ b/apps/users/templates/users/user_detail.html @@ -71,7 +71,7 @@ {% endif %} {% trans 'Role' %}: - {{ object.role_display }} + {{ object.org_role_display }} {% trans 'MFA' %}: diff --git a/apps/users/utils.py b/apps/users/utils.py index af6e0197b..cf6be4c67 100644 --- a/apps/users/utils.py +++ b/apps/users/utils.py @@ -315,7 +315,7 @@ def construct_user_email(username, email): def get_current_org_members(exclude=()): from orgs.utils import current_org - return current_org.get_org_members(exclude=exclude) + return current_org.get_members(exclude=exclude) def get_source_choices(): diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 932ab3b09..3a4897457 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -61,7 +61,7 @@ pytz==2018.3 PyYAML==5.1 redis==3.2.0 requests==2.22.0 -jms-storage==0.0.29 +jms-storage==0.0.31 s3transfer==0.3.3 simplejson==3.13.2 six==1.11.0