From 64641a18e6c8969b6291c2cca4fe21b08a30f07a Mon Sep 17 00:00:00 2001 From: "Jiangjie.Bai" <32935519+BaiJiangJie@users.noreply.github.com> Date: Thu, 11 Mar 2021 20:17:44 +0800 Subject: [PATCH] feat: ACL (#5696) * feature: acl (v0.1) * feature: acl (v0.2) * feature: acl (v0.3) * feature: acl (v0.4) * feature: acl (v0.5) * feature: acl (v0.6) * feature: acl (v0.7) * feature: acl (v0.8) * feature: acl (v0.9) * feature: acl (v1.0) * feature: acl (v1.1) * feature: acl (v1.2) * feature: acl (v1.3) * feature: acl (v1.4) * feature: acl (v1.5) * feature: acl (v1.6) * feature: acl (v1.7) * feature: acl (v1.8) * feature: acl (v1.9) * feature: acl (v2.0) * feature: acl (v2.1) * feature: acl (v2.2) * feature: acl (v2.3) * feature: acl (v2.4) * feature: acl (v2.5) * feature: acl (v2.6) * feature: acl (v2.7) * feature: acl (v2.8) * feature: acl (v2.9) * feature: acl (v3.0) * feature: acl (v3.1) * feature: acl (v3.2) * feature: acl (v3.3) * feature: acl (v3.4) * feature: acl (v3.5) * feature: acl (v3.6) * feature: acl (v3.7) * feature: acl (v3.8) * feature: acl (v3.9) * feature: acl (v4.0) * feature: acl (v4.1) * feature: acl (v4.2) * feature: acl (v4.3) * feature: acl (v4.4) --- apps/acls/__init__.py | 0 apps/acls/admin.py | 3 + apps/acls/api/__init__.py | 3 + apps/acls/api/login_acl.py | 19 + apps/acls/api/login_asset_acl.py | 15 + apps/acls/api/login_asset_check.py | 105 ++++ apps/acls/apps.py | 5 + apps/acls/const.py | 9 + apps/acls/migrations/0001_initial.py | 61 ++ apps/acls/migrations/__init__.py | 0 apps/acls/models/__init__.py | 2 + apps/acls/models/base.py | 35 ++ apps/acls/models/login_acl.py | 54 ++ apps/acls/models/login_asset_acl.py | 99 ++++ apps/acls/serializers/__init__.py | 3 + apps/acls/serializers/login_acl.py | 49 ++ apps/acls/serializers/login_asset_acl.py | 87 +++ apps/acls/serializers/login_asset_check.py | 71 +++ apps/acls/tests.py | 3 + apps/acls/urls/__init__.py | 1 + apps/acls/urls/api_urls.py | 18 + apps/acls/utils.py | 68 +++ .../migrations/0067_auto_20210311_1113.py | 48 ++ apps/assets/models/cmd_filter.py | 4 +- apps/assets/models/user.py | 2 +- apps/authentication/errors.py | 4 +- apps/authentication/mixins.py | 10 +- apps/common/permissions.py | 10 +- apps/common/utils/common.py | 2 +- apps/jumpserver/settings/base.py | 1 + apps/jumpserver/urls.py | 1 + apps/locale/zh/LC_MESSAGES/django.mo | Bin 70727 -> 73470 bytes apps/locale/zh/LC_MESSAGES/django.po | 561 ++++++++++-------- apps/orgs/utils.py | 2 - apps/terminal/models/storage.py | 2 +- apps/tickets/api/ticket.py | 4 +- apps/tickets/const.py | 1 + apps/tickets/handler/login_asset_confirm.py | 20 + .../migrations/0008_auto_20210311_1113.py | 18 + apps/tickets/serializers/ticket/meta/meta.py | 7 +- .../meta/ticket_type/login_asset_confirm.py | 21 + 41 files changed, 1166 insertions(+), 262 deletions(-) create mode 100644 apps/acls/__init__.py create mode 100644 apps/acls/admin.py create mode 100644 apps/acls/api/__init__.py create mode 100644 apps/acls/api/login_acl.py create mode 100644 apps/acls/api/login_asset_acl.py create mode 100644 apps/acls/api/login_asset_check.py create mode 100644 apps/acls/apps.py create mode 100644 apps/acls/const.py create mode 100644 apps/acls/migrations/0001_initial.py create mode 100644 apps/acls/migrations/__init__.py create mode 100644 apps/acls/models/__init__.py create mode 100644 apps/acls/models/base.py create mode 100644 apps/acls/models/login_acl.py create mode 100644 apps/acls/models/login_asset_acl.py create mode 100644 apps/acls/serializers/__init__.py create mode 100644 apps/acls/serializers/login_acl.py create mode 100644 apps/acls/serializers/login_asset_acl.py create mode 100644 apps/acls/serializers/login_asset_check.py create mode 100644 apps/acls/tests.py create mode 100644 apps/acls/urls/__init__.py create mode 100644 apps/acls/urls/api_urls.py create mode 100644 apps/acls/utils.py create mode 100644 apps/assets/migrations/0067_auto_20210311_1113.py create mode 100644 apps/tickets/handler/login_asset_confirm.py create mode 100644 apps/tickets/migrations/0008_auto_20210311_1113.py create mode 100644 apps/tickets/serializers/ticket/meta/ticket_type/login_asset_confirm.py diff --git a/apps/acls/__init__.py b/apps/acls/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/acls/admin.py b/apps/acls/admin.py new file mode 100644 index 000000000..8c38f3f3d --- /dev/null +++ b/apps/acls/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/apps/acls/api/__init__.py b/apps/acls/api/__init__.py new file mode 100644 index 000000000..ff52a1ce9 --- /dev/null +++ b/apps/acls/api/__init__.py @@ -0,0 +1,3 @@ +from .login_acl import * +from .login_asset_acl import * +from .login_asset_check import * diff --git a/apps/acls/api/login_acl.py b/apps/acls/api/login_acl.py new file mode 100644 index 000000000..2a74e2d0b --- /dev/null +++ b/apps/acls/api/login_acl.py @@ -0,0 +1,19 @@ +from common.permissions import IsOrgAdmin, HasQueryParamsUserAndIsCurrentOrgMember +from common.drf.api import JMSBulkModelViewSet +from ..models import LoginACL +from .. import serializers + +__all__ = ['LoginACLViewSet', ] + + +class LoginACLViewSet(JMSBulkModelViewSet): + queryset = LoginACL.objects.all() + filterset_fields = ('name', 'user', ) + search_fields = filterset_fields + permission_classes = (IsOrgAdmin, ) + serializer_class = serializers.LoginACLSerializer + + def get_permissions(self): + if self.action in ["retrieve", "list"]: + self.permission_classes = (IsOrgAdmin, HasQueryParamsUserAndIsCurrentOrgMember) + return super().get_permissions() diff --git a/apps/acls/api/login_asset_acl.py b/apps/acls/api/login_asset_acl.py new file mode 100644 index 000000000..ab966184f --- /dev/null +++ b/apps/acls/api/login_asset_acl.py @@ -0,0 +1,15 @@ + +from orgs.mixins.api import OrgBulkModelViewSet +from common.permissions import IsOrgAdmin +from .. import models, serializers + + +__all__ = ['LoginAssetACLViewSet'] + + +class LoginAssetACLViewSet(OrgBulkModelViewSet): + model = models.LoginAssetACL + filterset_fields = ('name', ) + search_fields = filterset_fields + permission_classes = (IsOrgAdmin, ) + serializer_class = serializers.LoginAssetACLSerializer diff --git a/apps/acls/api/login_asset_check.py b/apps/acls/api/login_asset_check.py new file mode 100644 index 000000000..ed525e6cf --- /dev/null +++ b/apps/acls/api/login_asset_check.py @@ -0,0 +1,105 @@ +from django.shortcuts import get_object_or_404 +from rest_framework.response import Response +from rest_framework.generics import CreateAPIView, RetrieveDestroyAPIView + +from common.permissions import IsAppUser +from common.utils import reverse, lazyproperty +from orgs.utils import tmp_to_org, tmp_to_root_org +from tickets.models import Ticket +from ..models import LoginAssetACL +from .. import serializers + + +__all__ = ['LoginAssetCheckAPI', 'LoginAssetConfirmStatusAPI'] + + +class LoginAssetCheckAPI(CreateAPIView): + permission_classes = (IsAppUser, ) + serializer_class = serializers.LoginAssetCheckSerializer + + def create(self, request, *args, **kwargs): + is_need_confirm, response_data = self.check_if_need_confirm() + return Response(data=response_data, status=200) + + def check_if_need_confirm(self): + queries = { + 'user': self.serializer.user, 'asset': self.serializer.asset, + 'system_user': self.serializer.system_user, + 'action': LoginAssetACL.ActionChoices.login_confirm + } + with tmp_to_org(self.serializer.org): + acl = LoginAssetACL.filter(**queries).valid().first() + + if not acl: + is_need_confirm = False + response_data = {} + else: + is_need_confirm = True + response_data = self._get_response_data_of_need_confirm(acl) + response_data['need_confirm'] = is_need_confirm + return is_need_confirm, response_data + + def _get_response_data_of_need_confirm(self, acl): + ticket = LoginAssetACL.create_login_asset_confirm_ticket( + user=self.serializer.user, + asset=self.serializer.asset, + system_user=self.serializer.system_user, + assignees=acl.reviewers.all(), + org_id=self.serializer.org.id + ) + confirm_status_url = reverse( + view_name='acls:login-asset-confirm-status', + kwargs={'pk': str(ticket.id)} + ) + ticket_detail_url = reverse( + view_name='api-tickets:ticket-detail', + kwargs={'pk': str(ticket.id)}, + external=True, api_to_ui=True + ) + ticket_detail_url = '{url}?type={type}'.format(url=ticket_detail_url, type=ticket.type) + data = { + 'check_confirm_status': {'method': 'GET', 'url': confirm_status_url}, + 'close_confirm': {'method': 'DELETE', 'url': confirm_status_url}, + 'ticket_detail_url': ticket_detail_url, + 'reviewers': [str(user) for user in ticket.assignees.all()], + } + return data + + @lazyproperty + def serializer(self): + serializer = self.get_serializer(data=self.request.data) + serializer.is_valid(raise_exception=True) + return serializer + + +class LoginAssetConfirmStatusAPI(RetrieveDestroyAPIView): + permission_classes = (IsAppUser, ) + + def retrieve(self, request, *args, **kwargs): + if self.ticket.action_open: + status = 'await' + elif self.ticket.action_approve: + status = 'approve' + else: + status = 'reject' + data = { + 'status': status, + 'action': self.ticket.action, + 'processor': self.ticket.processor_display + } + return Response(data=data, status=200) + + def destroy(self, request, *args, **kwargs): + if self.ticket.status_open: + self.ticket.close(processor=self.ticket.applicant) + data = { + 'action': self.ticket.action, + 'status': self.ticket.status, + 'processor': self.ticket.processor_display + } + return Response(data=data, status=200) + + @lazyproperty + def ticket(self): + with tmp_to_root_org(): + return get_object_or_404(Ticket, pk=self.kwargs['pk']) diff --git a/apps/acls/apps.py b/apps/acls/apps.py new file mode 100644 index 000000000..2456a1b4f --- /dev/null +++ b/apps/acls/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class AclsConfig(AppConfig): + name = 'acls' diff --git a/apps/acls/const.py b/apps/acls/const.py new file mode 100644 index 000000000..e40d679c8 --- /dev/null +++ b/apps/acls/const.py @@ -0,0 +1,9 @@ +from django.utils.translation import ugettext as _ + + +common_help_text = _('Format for comma-delimited string, with * indicating a match all. ') + +ip_group_help_text = common_help_text + _( + 'Such as: ' + '192.168.10.1, 192.168.1.0/24, 10.1.1.1-10.1.1.20, 2001:db8:2de::e13, 2001:db8:1a:1110::/64 ' +) diff --git a/apps/acls/migrations/0001_initial.py b/apps/acls/migrations/0001_initial.py new file mode 100644 index 000000000..83ebc4bf1 --- /dev/null +++ b/apps/acls/migrations/0001_initial.py @@ -0,0 +1,61 @@ +# Generated by Django 3.1 on 2021-03-11 09:53 + +from django.conf import settings +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='LoginACL', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('created_by', models.CharField(blank=True, max_length=32, null=True, verbose_name='Created 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')), + ('name', models.CharField(max_length=128, verbose_name='Name')), + ('priority', models.IntegerField(default=50, help_text='1-100, the lower the value will be match first', validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)], verbose_name='Priority')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('comment', models.TextField(blank=True, default='', verbose_name='Comment')), + ('ip_group', models.JSONField(default=list, verbose_name='Login IP')), + ('action', models.CharField(choices=[('reject', 'Reject'), ('allow', 'Allow')], default='reject', max_length=64, verbose_name='Action')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='login_acls', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'ordering': ('priority', '-date_updated', 'name'), + }, + ), + migrations.CreateModel( + name='LoginAssetACL', + 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)), + ('created_by', models.CharField(blank=True, max_length=32, null=True, verbose_name='Created 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')), + ('name', models.CharField(max_length=128, verbose_name='Name')), + ('priority', models.IntegerField(default=50, help_text='1-100, the lower the value will be match first', validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)], verbose_name='Priority')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('comment', models.TextField(blank=True, default='', verbose_name='Comment')), + ('users', models.JSONField(verbose_name='User')), + ('system_users', models.JSONField(verbose_name='System User')), + ('assets', models.JSONField(verbose_name='Asset')), + ('action', models.CharField(choices=[('login_confirm', 'Login confirm')], default='login_confirm', max_length=64, verbose_name='Action')), + ('reviewers', models.ManyToManyField(blank=True, related_name='review_login_asset_acls', to=settings.AUTH_USER_MODEL, verbose_name='Reviewers')), + ], + options={ + 'ordering': ('priority', '-date_updated', 'name'), + 'unique_together': {('name', 'org_id')}, + }, + ), + ] diff --git a/apps/acls/migrations/__init__.py b/apps/acls/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/acls/models/__init__.py b/apps/acls/models/__init__.py new file mode 100644 index 000000000..45d49c378 --- /dev/null +++ b/apps/acls/models/__init__.py @@ -0,0 +1,2 @@ +from .login_acl import * +from .login_asset_acl import * diff --git a/apps/acls/models/base.py b/apps/acls/models/base.py new file mode 100644 index 000000000..73ab5c59c --- /dev/null +++ b/apps/acls/models/base.py @@ -0,0 +1,35 @@ +from django.db import models +from django.utils.translation import ugettext_lazy as _ +from django.core.validators import MinValueValidator, MaxValueValidator +from common.mixins import CommonModelMixin + + +__all__ = ['BaseACL', 'BaseACLQuerySet'] + + +class BaseACLQuerySet(models.QuerySet): + def active(self): + return self.filter(is_active=True) + + def inactive(self): + return self.filter(is_active=False) + + def valid(self): + return self.active() + + def invalid(self): + return self.inactive() + + +class BaseACL(CommonModelMixin): + name = models.CharField(max_length=128, verbose_name=_('Name')) + priority = models.IntegerField( + default=50, verbose_name=_("Priority"), + help_text=_("1-100, the lower the value will be match first"), + validators=[MinValueValidator(1), MaxValueValidator(100)] + ) + is_active = models.BooleanField(default=True, verbose_name=_("Active")) + comment = models.TextField(default='', blank=True, verbose_name=_('Comment')) + + class Meta: + abstract = True diff --git a/apps/acls/models/login_acl.py b/apps/acls/models/login_acl.py new file mode 100644 index 000000000..44d100098 --- /dev/null +++ b/apps/acls/models/login_acl.py @@ -0,0 +1,54 @@ + +from django.db import models +from django.utils.translation import ugettext_lazy as _ +from .base import BaseACL, BaseACLQuerySet +from ..utils import contains_ip + + +class ACLManager(models.Manager): + + def valid(self): + return self.get_queryset().valid() + + +class LoginACL(BaseACL): + class ActionChoices(models.TextChoices): + reject = 'reject', _('Reject') + allow = 'allow', _('Allow') + + # 条件 + ip_group = models.JSONField(default=list, verbose_name=_('Login IP')) + # 动作 + action = models.CharField( + max_length=64, choices=ActionChoices.choices, default=ActionChoices.reject, + verbose_name=_('Action') + ) + # 关联 + user = models.ForeignKey( + 'users.User', on_delete=models.CASCADE, related_name='login_acls', verbose_name=_('User') + ) + + objects = ACLManager.from_queryset(BaseACLQuerySet)() + + class Meta: + ordering = ('priority', '-date_updated', 'name') + + @property + def action_reject(self): + return self.action == self.ActionChoices.reject + + @property + def action_allow(self): + return self.action == self.ActionChoices.allow + + @staticmethod + def allow_user_to_login(user, ip): + acl = user.login_acls.valid().first() + if not acl: + return True + is_contained = contains_ip(ip, acl.ip_group) + if acl.action_allow and is_contained: + return True + if acl.action_reject and not is_contained: + return True + return False diff --git a/apps/acls/models/login_asset_acl.py b/apps/acls/models/login_asset_acl.py new file mode 100644 index 000000000..162665c83 --- /dev/null +++ b/apps/acls/models/login_asset_acl.py @@ -0,0 +1,99 @@ +from django.db import models +from django.db.models import Q +from django.utils.translation import ugettext_lazy as _ +from orgs.mixins.models import OrgModelMixin, OrgManager +from .base import BaseACL, BaseACLQuerySet +from ..utils import contains_ip + + +class ACLManager(OrgManager): + + def valid(self): + return self.get_queryset().valid() + + +class LoginAssetACL(BaseACL, OrgModelMixin): + class ActionChoices(models.TextChoices): + login_confirm = 'login_confirm', _('Login confirm') + + # 条件 + users = models.JSONField(verbose_name=_('User')) + system_users = models.JSONField(verbose_name=_('System User')) + assets = models.JSONField(verbose_name=_('Asset')) + # 动作 + action = models.CharField( + max_length=64, choices=ActionChoices.choices, default=ActionChoices.login_confirm, + verbose_name=_('Action') + ) + # 动作: 附加字段 + # - login_confirm + reviewers = models.ManyToManyField( + 'users.User', related_name='review_login_asset_acls', blank=True, + verbose_name=_("Reviewers") + ) + + objects = ACLManager.from_queryset(BaseACLQuerySet)() + + class Meta: + unique_together = ('name', 'org_id') + ordering = ('priority', '-date_updated', 'name') + + @classmethod + def filter(cls, user, asset, system_user, action): + queryset = cls.objects.filter(action=action) + queryset = cls.filter_user(user, queryset) + queryset = cls.filter_asset(asset, queryset) + queryset = cls.filter_system_user(system_user, queryset) + return queryset + + @classmethod + def filter_user(cls, user, queryset): + queryset = queryset.filter( + Q(users__username_group__contains=user.username) | + Q(users__username_group__contains='*') + ) + return queryset + + @classmethod + def filter_asset(cls, asset, queryset): + queryset = queryset.filter( + Q(assets__hostname_group__contains=asset.hostname) | + Q(assets__hostname_group__contains='*') + ) + ids = [q.id for q in queryset if contains_ip(asset.ip, q.assets.get('ip_group', []))] + queryset = cls.objects.filter(id__in=ids) + return queryset + + @classmethod + def filter_system_user(cls, system_user, queryset): + queryset = queryset.filter( + Q(system_users__name_group__contains=system_user.name) | + Q(system_users__name_group__contains='*') + ).filter( + Q(system_users__username_group__contains=system_user.username) | + Q(system_users__username_group__contains='*') + ).filter( + Q(system_users__protocol_group__contains=system_user.protocol) | + Q(system_users__protocol_group__contains='*') + ) + return queryset + + @classmethod + def create_login_asset_confirm_ticket(cls, user, asset, system_user, assignees, org_id): + from tickets.const import TicketTypeChoices + from tickets.models import Ticket + data = { + 'title': _('Login asset confirm') + ' ({})'.format(user), + 'type': TicketTypeChoices.login_asset_confirm, + 'meta': { + 'apply_login_user': str(user), + 'apply_login_asset': str(asset), + 'apply_login_system_user': str(system_user), + }, + 'org_id': org_id, + } + ticket = Ticket.objects.create(**data) + ticket.assignees.set(assignees) + ticket.open(applicant=user) + return ticket + diff --git a/apps/acls/serializers/__init__.py b/apps/acls/serializers/__init__.py new file mode 100644 index 000000000..ff52a1ce9 --- /dev/null +++ b/apps/acls/serializers/__init__.py @@ -0,0 +1,3 @@ +from .login_acl import * +from .login_asset_acl import * +from .login_asset_check import * diff --git a/apps/acls/serializers/login_acl.py b/apps/acls/serializers/login_acl.py new file mode 100644 index 000000000..23d31b13e --- /dev/null +++ b/apps/acls/serializers/login_acl.py @@ -0,0 +1,49 @@ +from django.utils.translation import ugettext as _ +from rest_framework import serializers +from common.drf.serializers import BulkModelSerializer +from orgs.utils import current_org +from ..models import LoginACL +from ..utils import is_ip_address, is_ip_network, is_ip_segment +from .. import const + + +__all__ = ['LoginACLSerializer', ] + + +def ip_group_child_validator(ip_group_child): + is_valid = ip_group_child == '*' \ + or is_ip_address(ip_group_child) \ + or is_ip_network(ip_group_child) \ + or is_ip_segment(ip_group_child) + if not is_valid: + error = _('IP address invalid: `{}`').format(ip_group_child) + raise serializers.ValidationError(error) + + +class LoginACLSerializer(BulkModelSerializer): + ip_group = serializers.ListField( + default=['*'], label=_('IP'), help_text=const.ip_group_help_text, + child=serializers.CharField(max_length=1024, validators=[ip_group_child_validator]) + ) + user_display = serializers.ReadOnlyField(source='user.name', label=_('User')) + action_display = serializers.ReadOnlyField(source='get_action_display', label=_('Action')) + + class Meta: + model = LoginACL + fields = [ + 'id', 'name', 'priority', 'ip_group', 'user', 'user_display', 'action', + 'action_display', 'is_active', 'comment', 'created_by', 'date_created', 'date_updated' + ] + extra_kwargs = { + 'priority': {'default': 50}, + 'is_active': {'default': True}, + } + + @staticmethod + def validate_user(user): + if user not in current_org.get_members(): + error = _('The user `{}` is not in the current organization: `{}`').format( + user, current_org + ) + raise serializers.ValidationError(error) + return user diff --git a/apps/acls/serializers/login_asset_acl.py b/apps/acls/serializers/login_asset_acl.py new file mode 100644 index 000000000..863d636dc --- /dev/null +++ b/apps/acls/serializers/login_asset_acl.py @@ -0,0 +1,87 @@ +from rest_framework import serializers +from django.utils.translation import ugettext as _ +from orgs.mixins.serializers import BulkOrgResourceModelSerializer +from assets.models import SystemUser +from acls import models +from orgs.models import Organization +from .. import const + + +__all__ = ['LoginAssetACLSerializer'] + + +class LoginAssetACLUsersSerializer(serializers.Serializer): + username_group = serializers.ListField( + default=['*'], child=serializers.CharField(max_length=128), label=_('Username'), + help_text=const.common_help_text + ) + + +class LoginAssetACLAssestsSerializer(serializers.Serializer): + ip_group = serializers.ListField( + default=['*'], child=serializers.CharField(max_length=1024), label=_('IP'), + help_text=const.ip_group_help_text + _('(Domain name support)') + ) + hostname_group = serializers.ListField( + default=['*'], child=serializers.CharField(max_length=128), label=_('Hostname'), + help_text=const.common_help_text + ) + + +class LoginAssetACLSystemUsersSerializer(serializers.Serializer): + name_group = serializers.ListField( + default=['*'], child=serializers.CharField(max_length=128), label=_('Name'), + help_text=const.common_help_text + ) + username_group = serializers.ListField( + default=['*'], child=serializers.CharField(max_length=128), label=_('Username'), + help_text=const.common_help_text + ) + protocol_group = serializers.ListField( + default=['*'], child=serializers.CharField(max_length=16), label=_('Protocol'), + help_text=const.common_help_text + _('Protocol options: {}').format( + ', '.join(SystemUser.ASSET_CATEGORY_PROTOCOLS) + ) + ) + + @staticmethod + def validate_protocol_group(protocol_group): + unsupported_protocols = set(protocol_group) - set(SystemUser.ASSET_CATEGORY_PROTOCOLS + ['*']) + if unsupported_protocols: + error = _('Unsupported protocols: {}').format(unsupported_protocols) + raise serializers.ValidationError(error) + return protocol_group + + +class LoginAssetACLSerializer(BulkOrgResourceModelSerializer): + users = LoginAssetACLUsersSerializer() + assets = LoginAssetACLAssestsSerializer() + system_users = LoginAssetACLSystemUsersSerializer() + reviewers_amount = serializers.IntegerField(read_only=True, source='reviewers.count') + action_display = serializers.ReadOnlyField(source='get_action_display', label=_('Action')) + + class Meta: + model = models.LoginAssetACL + fields = [ + 'id', 'name', 'priority', 'users', 'system_users', 'assets', 'action', 'action_display', + 'is_active', 'comment', 'reviewers', 'reviewers_amount', 'created_by', 'date_created', + 'date_updated', 'org_id' + ] + extra_kwargs = { + "reviewers": {'allow_null': False, 'required': True}, + 'priority': {'default': 50}, + 'is_active': {'default': True}, + } + + def validate_reviewers(self, reviewers): + org_id = self.fields['org_id'].default() + org = Organization.get_instance(org_id) + if not org: + error = _('The organization `{}` does not exist'.format(org_id)) + raise serializers.ValidationError(error) + users = org.get_members() + valid_reviewers = list(set(reviewers) & set(users)) + if not valid_reviewers: + error = _('None of the reviewers belong to Organization `{}`'.format(org.name)) + raise serializers.ValidationError(error) + return valid_reviewers diff --git a/apps/acls/serializers/login_asset_check.py b/apps/acls/serializers/login_asset_check.py new file mode 100644 index 000000000..ec7a7c35c --- /dev/null +++ b/apps/acls/serializers/login_asset_check.py @@ -0,0 +1,71 @@ +from django.utils.translation import ugettext_lazy as _ +from rest_framework import serializers +from orgs.utils import tmp_to_root_org +from common.utils import get_object_or_none, lazyproperty +from users.models import User +from assets.models import Asset, SystemUser + + +__all__ = ['LoginAssetCheckSerializer'] + + +class LoginAssetCheckSerializer(serializers.Serializer): + user_id = serializers.UUIDField(required=True, allow_null=False) + asset_id = serializers.UUIDField(required=True, allow_null=False) + system_user_id = serializers.UUIDField(required=True, allow_null=False) + system_user_username = serializers.CharField(max_length=128, default='') + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.user = None + self.asset = None + self._system_user = None + self._system_user_username = None + + def validate_user_id(self, user_id): + self.user = self.validate_object_exist(User, user_id) + return user_id + + def validate_asset_id(self, asset_id): + self.asset = self.validate_object_exist(Asset, asset_id) + return asset_id + + def validate_system_user_id(self, system_user_id): + self._system_user = self.validate_object_exist(SystemUser, system_user_id) + return system_user_id + + def validate_system_user_username(self, system_user_username): + system_user_id = self.initial_data.get('system_user_id') + system_user = self.validate_object_exist(SystemUser, system_user_id) + if self._system_user.login_mode == SystemUser.LOGIN_MANUAL \ + and not system_user.username \ + and not system_user.username_same_with_user \ + and not system_user_username: + error = 'Missing parameter: system_user_username' + raise serializers.ValidationError(error) + self._system_user_username = system_user_username + return system_user_username + + @staticmethod + def validate_object_exist(model, field_id): + with tmp_to_root_org(): + obj = get_object_or_none(model, pk=field_id) + if not obj: + error = '{} Model object does not exist'.format(model.__name__) + raise serializers.ValidationError(error) + return obj + + @lazyproperty + def system_user(self): + if self._system_user.username_same_with_user: + username = self.user.username + elif self._system_user.login_mode == SystemUser.LOGIN_MANUAL: + username = self._system_user_username + else: + username = self._system_user.username + self._system_user.username = username + return self._system_user + + @lazyproperty + def org(self): + return self.asset.org diff --git a/apps/acls/tests.py b/apps/acls/tests.py new file mode 100644 index 000000000..7ce503c2d --- /dev/null +++ b/apps/acls/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/acls/urls/__init__.py b/apps/acls/urls/__init__.py new file mode 100644 index 000000000..4d8423837 --- /dev/null +++ b/apps/acls/urls/__init__.py @@ -0,0 +1 @@ +from .api_urls import * diff --git a/apps/acls/urls/api_urls.py b/apps/acls/urls/api_urls.py new file mode 100644 index 000000000..24fbc11b0 --- /dev/null +++ b/apps/acls/urls/api_urls.py @@ -0,0 +1,18 @@ +from django.urls import path +from rest_framework_bulk.routes import BulkRouter +from .. import api + + +app_name = 'acls' + + +router = BulkRouter() +router.register(r'login-acls', api.LoginACLViewSet, 'login-acl') +router.register(r'login-asset-acls', api.LoginAssetACLViewSet, 'login-asset-acl') + +urlpatterns = [ + path('login-asset/check/', api.LoginAssetCheckAPI.as_view(), name='login-asset-check'), + path('login-asset-confirm//status/', api.LoginAssetConfirmStatusAPI.as_view(), name='login-asset-confirm-status') +] + +urlpatterns += router.urls diff --git a/apps/acls/utils.py b/apps/acls/utils.py new file mode 100644 index 000000000..0000a5da7 --- /dev/null +++ b/apps/acls/utils.py @@ -0,0 +1,68 @@ +from ipaddress import ip_network, ip_address + + +def is_ip_address(address): + """ 192.168.10.1 """ + try: + ip_address(address) + except ValueError: + return False + else: + return True + + +def is_ip_network(ip): + """ 192.168.1.0/24 """ + try: + ip_network(ip) + except ValueError: + return False + else: + return True + + +def is_ip_segment(ip): + """ 10.1.1.1-10.1.1.20 """ + if '-' not in ip: + return False + ip_address1, ip_address2 = ip.split('-') + return is_ip_address(ip_address1) and is_ip_address(ip_address2) + + +def in_ip_segment(ip, ip_segment): + ip1, ip2 = ip_segment.split('-') + ip1 = int(ip_address(ip1)) + ip2 = int(ip_address(ip2)) + ip = int(ip_address(ip)) + return min(ip1, ip2) <= ip <= max(ip1, ip2) + + +def contains_ip(ip, ip_group): + """ + ip_group: + [192.168.10.1, 192.168.1.0/24, 10.1.1.1-10.1.1.20, 2001:db8:2de::e13, 2001:db8:1a:1110::/64.] + + """ + + if '*' in ip_group: + return True + + for _ip in ip_group: + if is_ip_address(_ip): + # 192.168.10.1 + if ip == _ip: + return True + elif is_ip_network(_ip) and is_ip_address(ip): + # 192.168.1.0/24 + if ip_address(ip) in ip_network(_ip): + return True + elif is_ip_segment(_ip) and is_ip_address(ip): + # 10.1.1.1-10.1.1.20 + if in_ip_segment(ip, _ip): + return True + else: + # is domain name + if ip == _ip: + return True + + return False diff --git a/apps/assets/migrations/0067_auto_20210311_1113.py b/apps/assets/migrations/0067_auto_20210311_1113.py new file mode 100644 index 000000000..0b087d47c --- /dev/null +++ b/apps/assets/migrations/0067_auto_20210311_1113.py @@ -0,0 +1,48 @@ +# Generated by Django 3.1 on 2021-03-11 03:13 + +import django.core.validators +from django.db import migrations, models + + +def migrate_cmd_filter_priority(apps, schema_editor): + cmd_filter_rule_model = apps.get_model('assets', 'CommandFilterRule') + cmd_filter_rules = cmd_filter_rule_model.objects.all() + for cmd_filter_rule in cmd_filter_rules: + cmd_filter_rule.priority = 100 - cmd_filter_rule.priority + 1 + + cmd_filter_rule_model.objects.bulk_update(cmd_filter_rules, fields=['priority']) + + +def migrate_system_user_priority(apps, schema_editor): + system_user_model = apps.get_model('assets', 'SystemUser') + system_users = system_user_model.objects.all() + for system_user in system_users: + system_user.priority = 100 - system_user.priority + 1 + + system_user_model.objects.bulk_update(system_users, fields=['priority']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('assets', '0066_auto_20210208_1802'), + ] + + operations = [ + migrations.RunPython(migrate_cmd_filter_priority), + migrations.RunPython(migrate_system_user_priority), + migrations.AlterModelOptions( + name='commandfilterrule', + options={'ordering': ('priority', 'action'), 'verbose_name': 'Command filter rule'}, + ), + migrations.AlterField( + model_name='commandfilterrule', + name='priority', + field=models.IntegerField(default=50, help_text='1-100, the lower the value will be match first', validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)], verbose_name='Priority'), + ), + migrations.AlterField( + model_name='systemuser', + name='priority', + field=models.IntegerField(default=20, help_text='1-100, the lower the value will be match first', validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)], verbose_name='Priority'), + ), + ] diff --git a/apps/assets/models/cmd_filter.py b/apps/assets/models/cmd_filter.py index 204e98f94..c1242fd7e 100644 --- a/apps/assets/models/cmd_filter.py +++ b/apps/assets/models/cmd_filter.py @@ -50,7 +50,7 @@ class CommandFilterRule(OrgModelMixin): id = models.UUIDField(default=uuid.uuid4, primary_key=True) filter = models.ForeignKey('CommandFilter', on_delete=models.CASCADE, verbose_name=_("Filter"), related_name='rules') type = models.CharField(max_length=16, default=TYPE_COMMAND, choices=TYPE_CHOICES, verbose_name=_("Type")) - priority = models.IntegerField(default=50, verbose_name=_("Priority"), help_text=_("1-100, the higher will be match first"), + priority = models.IntegerField(default=50, verbose_name=_("Priority"), help_text=_("1-100, the lower the value will be match first"), validators=[MinValueValidator(1), MaxValueValidator(100)]) content = models.TextField(verbose_name=_("Content"), help_text=_("One line one command")) action = models.IntegerField(default=ACTION_DENY, choices=ACTION_CHOICES, verbose_name=_("Action")) @@ -60,7 +60,7 @@ class CommandFilterRule(OrgModelMixin): created_by = models.CharField(max_length=128, blank=True, default='', verbose_name=_('Created by')) class Meta: - ordering = ('-priority', 'action') + ordering = ('priority', 'action') verbose_name = _("Command filter rule") @lazyproperty diff --git a/apps/assets/models/user.py b/apps/assets/models/user.py index dd576b077..0145af2f9 100644 --- a/apps/assets/models/user.py +++ b/apps/assets/models/user.py @@ -116,7 +116,7 @@ class SystemUser(BaseUser): assets = models.ManyToManyField('assets.Asset', blank=True, verbose_name=_("Assets")) users = models.ManyToManyField('users.User', blank=True, verbose_name=_("Users")) groups = models.ManyToManyField('users.UserGroup', blank=True, verbose_name=_("User groups")) - priority = models.IntegerField(default=20, verbose_name=_("Priority"), validators=[MinValueValidator(1), MaxValueValidator(100)]) + priority = models.IntegerField(default=20, verbose_name=_("Priority"), help_text=_("1-100, the lower the value will be match first"), validators=[MinValueValidator(1), MaxValueValidator(100)]) protocol = models.CharField(max_length=16, choices=PROTOCOL_CHOICES, default='ssh', verbose_name=_('Protocol')) auto_push = models.BooleanField(default=True, verbose_name=_('Auto push')) sudo = models.TextField(default='/bin/whoami', verbose_name=_('Sudo')) diff --git a/apps/authentication/errors.py b/apps/authentication/errors.py index 679c0b748..68c85ff87 100644 --- a/apps/authentication/errors.py +++ b/apps/authentication/errors.py @@ -19,6 +19,7 @@ reason_password_expired = 'password_expired' reason_user_invalid = 'user_invalid' reason_user_inactive = 'user_inactive' reason_backend_not_match = 'backend_not_match' +reason_acl_not_allow = 'acl_not_allow' reason_choices = { reason_password_failed: _('Username/password check failed'), @@ -29,7 +30,8 @@ reason_choices = { reason_password_expired: _("Password expired"), reason_user_invalid: _('Disabled or expired'), reason_user_inactive: _("This account is inactive."), - reason_backend_not_match: _("Auth backend not match") + reason_backend_not_match: _("Auth backend not match"), + reason_acl_not_allow: _("ACL is not allowed") } old_reason_choices = { '0': '-', diff --git a/apps/authentication/mixins.py b/apps/authentication/mixins.py index 9702e6046..f89938e64 100644 --- a/apps/authentication/mixins.py +++ b/apps/authentication/mixins.py @@ -128,6 +128,13 @@ class AuthMixin: if auth_backend not in auth_backends_allowed: self.raise_credential_error(error=errors.reason_backend_not_match) + def _check_login_acl(self, user, ip): + # ACL 限制用户登录 + from acls.models import LoginACL + is_allowed = LoginACL.allow_user_to_login(user, ip) + if not is_allowed: + raise self.raise_credential_error(error=errors.reason_acl_not_allow) + def check_user_auth(self, decrypt_passwd=False): self.check_is_block() request = self.request @@ -135,8 +142,9 @@ class AuthMixin: self._check_only_allow_exists_user_auth(username) user = self._check_auth_user_is_valid(username, password, public_key) + # 校验login-acl规则 + self._check_login_acl(user, ip) # 限制只能从认证来源登录 - auth_backend = getattr(user, 'backend', 'django.contrib.auth.backends.ModelBackend') self._check_auth_source_is_valid(user, auth_backend) self._check_password_require_reset_or_not(user) diff --git a/apps/common/permissions.py b/apps/common/permissions.py index 7098cdb84..32ea8ca94 100644 --- a/apps/common/permissions.py +++ b/apps/common/permissions.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- # import time - from rest_framework import permissions from django.contrib.auth.mixins import UserPassesTestMixin from django.conf import settings @@ -193,3 +192,12 @@ class IsObjectOwner(IsValidUser): def has_object_permission(self, request, view, obj): return (super().has_object_permission(request, view, obj) and request.user == getattr(obj, 'user', None)) + + +class HasQueryParamsUserAndIsCurrentOrgMember(permissions.BasePermission): + def has_permission(self, request, view): + query_user_id = request.query_params.get('user') + if not query_user_id: + return False + query_user = current_org.get_members().filter(id=query_user_id).first() + return bool(query_user) diff --git a/apps/common/utils/common.py b/apps/common/utils/common.py index e24793b55..0e910cf16 100644 --- a/apps/common/utils/common.py +++ b/apps/common/utils/common.py @@ -142,7 +142,7 @@ def is_uuid(seq): elif isinstance(seq, str) and UUID_PATTERN.match(seq): return True elif isinstance(seq, (list, tuple)): - all([is_uuid(x) for x in seq]) + return all([is_uuid(x) for x in seq]) return False diff --git a/apps/jumpserver/settings/base.py b/apps/jumpserver/settings/base.py index 1766fb41f..3e5a4ff6a 100644 --- a/apps/jumpserver/settings/base.py +++ b/apps/jumpserver/settings/base.py @@ -48,6 +48,7 @@ INSTALLED_APPS = [ 'authentication.apps.AuthenticationConfig', # authentication 'applications.apps.ApplicationsConfig', 'tickets.apps.TicketsConfig', + 'acls.apps.AclsConfig', 'jms_oidc_rp', 'rest_framework', 'rest_framework_swagger', diff --git a/apps/jumpserver/urls.py b/apps/jumpserver/urls.py index 5b8202854..044d09310 100644 --- a/apps/jumpserver/urls.py +++ b/apps/jumpserver/urls.py @@ -22,6 +22,7 @@ api_v1 = [ path('common/', include('common.urls.api_urls', namespace='api-common')), path('applications/', include('applications.urls.api_urls', namespace='api-applications')), path('tickets/', include('tickets.urls.api_urls', namespace='api-tickets')), + path('acls/', include('acls.urls.api_urls', namespace='api-acls')), path('prometheus/metrics/', api.PrometheusMetricsApi.as_view()) ] diff --git a/apps/locale/zh/LC_MESSAGES/django.mo b/apps/locale/zh/LC_MESSAGES/django.mo index 850c925033ef86eb4934b9de83781965352a5989..3a6dfef4d1b51da4c84fe5c8a1cb1d77af8d10cb 100644 GIT binary patch delta 24040 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 diff --git a/apps/locale/zh/LC_MESSAGES/django.po b/apps/locale/zh/LC_MESSAGES/django.po index a377a5384..20e7cc254 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: 2021-03-10 11:16+0800\n" +"POT-Creation-Date: 2021-03-11 17:54+0800\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: ibuler \n" "Language-Team: JumpServer team\n" @@ -17,21 +17,19 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: applications/const.py:9 -#: applications/serializers/attrs/application_category/db.py:14 -#: applications/serializers/attrs/application_type/mysql_workbench.py:26 -#: users/templates/users/user_granted_database_app.html:37 -msgid "Database" -msgstr "数据库" +#: acls/const.py:4 +msgid "Format for comma-delimited string, with * indicating a match all. " +msgstr "格式为逗号分隔的字符串, * 表示匹配所有. " -#: applications/const.py:10 -msgid "Remote app" -msgstr "远程应用" - -#: applications/const.py:29 -msgid "Custom" -msgstr "自定义" +#: acls/const.py:7 +msgid "" +"Such as: 192.168.10.1, 192.168.1.0/24, 10.1.1.1-10.1.1.20, 2001:db8:2de::" +"e13, 2001:db8:1a:1110::/64 " +msgstr "" +"例如: 192.168.10.1, 192.168.1.0/24, 10.1.1.1-10.1.1.20, 2001:db8:2de::e13, " +"2001:db8:1a:1110::/64" +#: acls/models/base.py:25 acls/serializers/login_asset_acl.py:33 #: applications/models/application.py:11 assets/models/asset.py:142 #: assets/models/base.py:250 assets/models/cluster.py:18 #: assets/models/cmd_filter.py:21 assets/models/domain.py:21 @@ -57,6 +55,216 @@ msgstr "自定义" msgid "Name" msgstr "名称" +#: acls/models/base.py:27 assets/models/cmd_filter.py:53 +#: assets/models/user.py:119 +msgid "Priority" +msgstr "优先级" + +#: acls/models/base.py:28 assets/models/cmd_filter.py:53 +#: assets/models/user.py:119 +msgid "1-100, the lower the value will be match first" +msgstr "优先级可选范围为 1-100 (数值越小越优先)" + +#: acls/models/base.py:31 authentication/models.py:20 +#: authentication/templates/authentication/_access_key_modal.html:32 +#: perms/models/base.py:52 users/templates/users/_select_user_modal.html:18 +#: users/templates/users/user_detail.html:132 +#: users/templates/users/user_profile.html:63 +msgid "Active" +msgstr "激活中" + +# msgid "Date created" +# msgstr "创建日期" +#: acls/models/base.py:32 applications/models/application.py:24 +#: assets/models/asset.py:147 assets/models/asset.py:223 +#: assets/models/base.py:255 assets/models/cluster.py:29 +#: assets/models/cmd_filter.py:23 assets/models/cmd_filter.py:57 +#: assets/models/domain.py:22 assets/models/domain.py:56 +#: assets/models/group.py:23 assets/models/label.py:23 ops/models/adhoc.py:37 +#: orgs/models.py:26 perms/models/base.py:57 settings/models.py:34 +#: terminal/models/storage.py:29 terminal/models/storage.py:81 +#: terminal/models/terminal.py:153 tickets/models/ticket.py:73 +#: users/models/group.py:16 users/models/user.py:563 +#: 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 +#: users/templates/users/user_group_list.html:16 +#: users/templates/users/user_profile.html:138 +#: xpack/plugins/change_auth_plan/models.py:77 xpack/plugins/cloud/models.py:35 +#: xpack/plugins/cloud/models.py:98 xpack/plugins/gathered_user/models.py:26 +msgid "Comment" +msgstr "备注" + +#: acls/models/login_acl.py:16 tickets/const.py:18 +msgid "Reject" +msgstr "拒绝" + +#: acls/models/login_acl.py:17 assets/models/cmd_filter.py:47 +msgid "Allow" +msgstr "允许" + +#: acls/models/login_acl.py:20 +msgid "Login IP" +msgstr "登录IP" + +#: acls/models/login_acl.py:24 acls/models/login_asset_acl.py:26 +#: acls/serializers/login_acl.py:29 acls/serializers/login_asset_acl.py:61 +#: assets/models/cmd_filter.py:56 audits/models.py:57 +#: authentication/templates/authentication/_access_key_modal.html:34 +#: tickets/models/ticket.py:43 users/templates/users/_granted_assets.html:29 +#: users/templates/users/user_asset_permission.html:44 +#: users/templates/users/user_asset_permission.html:79 +#: users/templates/users/user_database_app_permission.html:42 +#: users/templates/users/user_group_list.html:17 +#: users/templates/users/user_list.html:20 +#: users/templates/users/user_remote_app_permission.html:42 +msgid "Action" +msgstr "动作" + +#: acls/models/login_acl.py:28 acls/models/login_asset_acl.py:20 +#: acls/serializers/login_acl.py:28 assets/models/label.py:15 +#: audits/models.py:36 audits/models.py:56 audits/models.py:69 +#: audits/serializers.py:81 authentication/models.py:44 +#: authentication/models.py:95 orgs/models.py:18 orgs/models.py:403 +#: perms/models/base.py:50 templates/index.html:78 +#: terminal/backends/command/models.py:18 +#: terminal/backends/command/serializers.py:12 terminal/models/session.py:37 +#: tickets/models/comment.py:17 users/models/user.py:159 +#: users/models/user.py:677 users/serializers/group.py:20 +#: users/templates/users/user_asset_permission.html:38 +#: users/templates/users/user_asset_permission.html:64 +#: users/templates/users/user_database_app_permission.html:37 +#: users/templates/users/user_database_app_permission.html:58 +#: users/templates/users/user_group_detail.html:73 +#: users/templates/users/user_group_list.html:15 +#: users/templates/users/user_list.html:135 +#: users/templates/users/user_remote_app_permission.html:37 +#: users/templates/users/user_remote_app_permission.html:58 +msgid "User" +msgstr "用户" + +#: acls/models/login_asset_acl.py:17 authentication/models.py:71 +#: tickets/const.py:9 users/templates/users/user_detail.html:250 +msgid "Login confirm" +msgstr "登录复核" + +#: acls/models/login_asset_acl.py:21 +msgid "System User" +msgstr "系统用户" + +#: acls/models/login_asset_acl.py:22 +#: applications/serializers/attrs/application_category/remote_app.py:33 +#: assets/models/asset.py:355 assets/models/authbook.py:26 +#: assets/models/gathered_user.py:14 assets/serializers/admin_user.py:29 +#: assets/serializers/asset_user.py:47 assets/serializers/asset_user.py:84 +#: assets/serializers/system_user.py:191 audits/models.py:38 +#: perms/models/asset_permission.py:99 templates/index.html:82 +#: terminal/backends/command/models.py:19 +#: terminal/backends/command/serializers.py:13 terminal/models/session.py:39 +#: users/templates/users/user_asset_permission.html:40 +#: users/templates/users/user_asset_permission.html:70 +#: users/templates/users/user_granted_remote_app.html:36 +#: xpack/plugins/change_auth_plan/models.py:282 +#: xpack/plugins/cloud/models.py:202 +msgid "Asset" +msgstr "资产" + +#: acls/models/login_asset_acl.py:32 authentication/models.py:45 +#: users/templates/users/user_detail.html:258 +msgid "Reviewers" +msgstr "审批人" + +#: acls/models/login_asset_acl.py:86 tickets/const.py:12 +msgid "Login asset confirm" +msgstr "登录资产复核" + +#: acls/serializers/login_acl.py:19 +msgid "IP address invalid: `{}`" +msgstr "IP 地址无效: `{}`" + +#: acls/serializers/login_acl.py:25 acls/serializers/login_asset_acl.py:22 +#: applications/serializers/attrs/application_type/mysql_workbench.py:18 +#: assets/models/asset.py:183 assets/models/domain.py:52 +#: assets/serializers/asset_user.py:46 settings/serializers/settings.py:108 +#: users/templates/users/_granted_assets.html:26 +#: users/templates/users/user_asset_permission.html:156 +msgid "IP" +msgstr "IP" + +#: acls/serializers/login_acl.py:41 +msgid "The user `{}` is not in the current organization: `{}`" +msgstr "用户 `{}` 不在当前组织: `{}`" + +#: acls/serializers/login_asset_acl.py:15 +#: acls/serializers/login_asset_acl.py:37 +#: applications/serializers/attrs/application_type/chrome.py:20 +#: applications/serializers/attrs/application_type/custom.py:21 +#: applications/serializers/attrs/application_type/mysql_workbench.py:30 +#: applications/serializers/attrs/application_type/vmware_client.py:26 +#: assets/models/base.py:251 assets/models/gathered_user.py:15 +#: audits/models.py:99 authentication/forms.py:15 authentication/forms.py:17 +#: ops/models/adhoc.py:148 users/forms/profile.py:31 users/models/user.py:528 +#: users/templates/users/_select_user_modal.html:14 +#: users/templates/users/user_detail.html:53 +#: users/templates/users/user_list.html:15 +#: users/templates/users/user_profile.html:47 +#: xpack/plugins/change_auth_plan/models.py:47 +#: xpack/plugins/change_auth_plan/models.py:278 +#: xpack/plugins/cloud/serializers.py:44 +msgid "Username" +msgstr "用户名" + +#: acls/serializers/login_asset_acl.py:23 +msgid "(Domain name support)" +msgstr "(支持域名)" + +#: acls/serializers/login_asset_acl.py:26 assets/models/asset.py:184 +#: assets/serializers/asset_user.py:45 assets/serializers/gathered_user.py:20 +#: settings/serializers/settings.py:107 +#: users/templates/users/_granted_assets.html:25 +#: users/templates/users/user_asset_permission.html:157 +msgid "Hostname" +msgstr "主机名" + +#: acls/serializers/login_asset_acl.py:41 assets/models/asset.py:187 +#: assets/models/domain.py:54 assets/models/user.py:120 +#: terminal/serializers/session.py:29 terminal/serializers/storage.py:69 +msgid "Protocol" +msgstr "协议" + +#: acls/serializers/login_asset_acl.py:42 +msgid "Protocol options: {}" +msgstr "协议选项: {}" + +#: acls/serializers/login_asset_acl.py:51 +msgid "Unsupported protocols: {}" +msgstr "不支持的协议: {}" + +#: acls/serializers/login_asset_acl.py:78 +#: tickets/serializers/ticket/ticket.py:109 +msgid "The organization `{}` does not exist" +msgstr "组织 `{}` 不存在" + +#: acls/serializers/login_asset_acl.py:83 +msgid "None of the reviewers belong to Organization `{}`" +msgstr "所有复核人都不属于组织 `{}`" + +#: applications/const.py:9 +#: applications/serializers/attrs/application_category/db.py:14 +#: applications/serializers/attrs/application_type/mysql_workbench.py:26 +#: users/templates/users/user_granted_database_app.html:37 +msgid "Database" +msgstr "数据库" + +#: applications/const.py:10 +msgid "Remote app" +msgstr "远程应用" + +#: applications/const.py:29 +msgid "Custom" +msgstr "自定义" + #: applications/models/application.py:13 #: applications/serializers/application.py:47 assets/models/label.py:21 #: perms/models/application_permission.py:20 @@ -87,28 +295,6 @@ msgstr "网域" msgid "Attrs" msgstr "" -# msgid "Date created" -# msgstr "创建日期" -#: applications/models/application.py:24 assets/models/asset.py:147 -#: assets/models/asset.py:223 assets/models/base.py:255 -#: assets/models/cluster.py:29 assets/models/cmd_filter.py:23 -#: assets/models/cmd_filter.py:57 assets/models/domain.py:22 -#: assets/models/domain.py:56 assets/models/group.py:23 -#: assets/models/label.py:23 ops/models/adhoc.py:37 orgs/models.py:26 -#: perms/models/base.py:57 settings/models.py:34 terminal/models/storage.py:29 -#: terminal/models/storage.py:81 terminal/models/terminal.py:153 -#: tickets/models/ticket.py:73 users/models/group.py:16 -#: users/models/user.py:563 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 -#: users/templates/users/user_group_list.html:16 -#: users/templates/users/user_profile.html:138 -#: xpack/plugins/change_auth_plan/models.py:77 xpack/plugins/cloud/models.py:35 -#: xpack/plugins/cloud/models.py:98 xpack/plugins/gathered_user/models.py:26 -msgid "Comment" -msgstr "备注" - #: applications/serializers/attrs/application_category/cloud.py:9 #: assets/models/cluster.py:40 msgid "Cluster" @@ -131,22 +317,6 @@ msgstr "主机" msgid "Port" msgstr "端口" -#: applications/serializers/attrs/application_category/remote_app.py:33 -#: assets/models/asset.py:355 assets/models/authbook.py:26 -#: assets/models/gathered_user.py:14 assets/serializers/admin_user.py:29 -#: assets/serializers/asset_user.py:47 assets/serializers/asset_user.py:84 -#: assets/serializers/system_user.py:191 audits/models.py:38 -#: perms/models/asset_permission.py:99 templates/index.html:82 -#: terminal/backends/command/models.py:19 -#: terminal/backends/command/serializers.py:13 terminal/models/session.py:39 -#: users/templates/users/user_asset_permission.html:40 -#: users/templates/users/user_asset_permission.html:70 -#: users/templates/users/user_granted_remote_app.html:36 -#: xpack/plugins/change_auth_plan/models.py:282 -#: xpack/plugins/cloud/models.py:202 -msgid "Asset" -msgstr "资产" - #: applications/serializers/attrs/application_category/remote_app.py:36 #: applications/serializers/attrs/application_type/chrome.py:14 #: applications/serializers/attrs/application_type/mysql_workbench.py:14 @@ -159,23 +329,6 @@ msgstr "应用路径" msgid "Target URL" msgstr "目标URL" -#: applications/serializers/attrs/application_type/chrome.py:20 -#: applications/serializers/attrs/application_type/custom.py:21 -#: applications/serializers/attrs/application_type/mysql_workbench.py:30 -#: applications/serializers/attrs/application_type/vmware_client.py:26 -#: assets/models/base.py:251 assets/models/gathered_user.py:15 -#: audits/models.py:99 authentication/forms.py:15 authentication/forms.py:17 -#: ops/models/adhoc.py:148 users/forms/profile.py:31 users/models/user.py:528 -#: users/templates/users/_select_user_modal.html:14 -#: users/templates/users/user_detail.html:53 -#: users/templates/users/user_list.html:15 -#: users/templates/users/user_profile.html:47 -#: xpack/plugins/change_auth_plan/models.py:47 -#: xpack/plugins/change_auth_plan/models.py:278 -#: xpack/plugins/cloud/serializers.py:44 -msgid "Username" -msgstr "用户名" - #: applications/serializers/attrs/application_type/chrome.py:23 #: applications/serializers/attrs/application_type/custom.py:25 #: applications/serializers/attrs/application_type/mysql_workbench.py:34 @@ -205,14 +358,6 @@ msgstr "运行参数" msgid "Target url" msgstr "目标URL" -#: applications/serializers/attrs/application_type/mysql_workbench.py:18 -#: assets/models/asset.py:183 assets/models/domain.py:52 -#: assets/serializers/asset_user.py:46 settings/serializers/settings.py:108 -#: users/templates/users/_granted_assets.html:26 -#: users/templates/users/user_asset_permission.html:156 -msgid "IP" -msgstr "IP" - #: assets/api/admin_user.py:50 msgid "Deleted failed, There are related assets" msgstr "删除失败,存在关联资产" @@ -262,19 +407,6 @@ msgstr "内部的" msgid "Platform" msgstr "系统平台" -#: assets/models/asset.py:184 assets/serializers/asset_user.py:45 -#: assets/serializers/gathered_user.py:20 settings/serializers/settings.py:107 -#: users/templates/users/_granted_assets.html:25 -#: users/templates/users/user_asset_permission.html:157 -msgid "Hostname" -msgstr "主机名" - -#: assets/models/asset.py:187 assets/models/domain.py:54 -#: assets/models/user.py:120 terminal/serializers/session.py:29 -#: terminal/serializers/storage.py:69 -msgid "Protocol" -msgstr "协议" - #: assets/models/asset.py:189 assets/serializers/asset.py:68 #: perms/serializers/asset/user_permission.py:41 msgid "Protocols" @@ -484,22 +616,10 @@ msgstr "命令" msgid "Deny" msgstr "拒绝" -#: assets/models/cmd_filter.py:47 -msgid "Allow" -msgstr "允许" - #: assets/models/cmd_filter.py:51 msgid "Filter" msgstr "过滤器" -#: assets/models/cmd_filter.py:53 assets/models/user.py:119 -msgid "Priority" -msgstr "优先级" - -#: 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:55 xpack/plugins/license/models.py:29 msgid "Content" msgstr "内容" @@ -508,18 +628,6 @@ msgstr "内容" msgid "One line one command" msgstr "每行一个命令" -#: assets/models/cmd_filter.py:56 audits/models.py:57 -#: authentication/templates/authentication/_access_key_modal.html:34 -#: tickets/models/ticket.py:43 users/templates/users/_granted_assets.html:29 -#: users/templates/users/user_asset_permission.html:44 -#: users/templates/users/user_asset_permission.html:79 -#: users/templates/users/user_database_app_permission.html:42 -#: users/templates/users/user_group_list.html:17 -#: users/templates/users/user_list.html:20 -#: users/templates/users/user_remote_app_permission.html:42 -msgid "Action" -msgstr "动作" - #: assets/models/cmd_filter.py:64 msgid "Command filter rule" msgstr "命令过滤规则" @@ -556,27 +664,7 @@ msgstr "资产组" 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:81 authentication/models.py:44 -#: authentication/models.py:95 orgs/models.py:18 orgs/models.py:403 -#: perms/models/base.py:50 templates/index.html:78 -#: terminal/backends/command/models.py:18 -#: terminal/backends/command/serializers.py:12 terminal/models/session.py:37 -#: tickets/models/comment.py:17 users/models/user.py:159 -#: users/models/user.py:677 users/serializers/group.py:20 -#: users/templates/users/user_asset_permission.html:38 -#: users/templates/users/user_asset_permission.html:64 -#: users/templates/users/user_database_app_permission.html:37 -#: users/templates/users/user_database_app_permission.html:58 -#: users/templates/users/user_group_detail.html:73 -#: users/templates/users/user_group_list.html:15 -#: users/templates/users/user_list.html:135 -#: users/templates/users/user_remote_app_permission.html:37 -#: users/templates/users/user_remote_app_permission.html:58 -msgid "User" -msgstr "用户" - -#: assets/models/label.py:19 assets/models/node.py:553 settings/models.py:30 +#: assets/models/label.py:19 assets/models/node.py:575 settings/models.py:30 msgid "Value" msgstr "值" @@ -584,23 +672,23 @@ msgstr "值" msgid "New node" msgstr "新节点" -#: assets/models/node.py:445 users/templates/users/_granted_assets.html:130 +#: assets/models/node.py:467 users/templates/users/_granted_assets.html:130 msgid "empty" msgstr "空" -#: assets/models/node.py:552 perms/models/asset_permission.py:156 +#: assets/models/node.py:574 perms/models/asset_permission.py:156 msgid "Key" msgstr "键" -#: assets/models/node.py:554 +#: assets/models/node.py:576 msgid "Full value" msgstr "全称" -#: assets/models/node.py:557 perms/models/asset_permission.py:157 +#: assets/models/node.py:579 perms/models/asset_permission.py:157 msgid "Parent key" msgstr "ssh私钥" -#: assets/models/node.py:566 assets/serializers/system_user.py:190 +#: assets/models/node.py:588 assets/serializers/system_user.py:190 #: users/templates/users/user_asset_permission.html:41 #: users/templates/users/user_asset_permission.html:73 #: users/templates/users/user_asset_permission.html:158 @@ -1201,47 +1289,51 @@ msgstr "" msgid "Invalid token or cache refreshed." msgstr "" -#: authentication/errors.py:24 +#: authentication/errors.py:25 msgid "Username/password check failed" msgstr "用户名/密码 校验失败" -#: authentication/errors.py:25 +#: authentication/errors.py:26 msgid "Password decrypt failed" msgstr "密码解密失败" -#: authentication/errors.py:26 +#: authentication/errors.py:27 msgid "MFA failed" msgstr "多因子认证失败" -#: authentication/errors.py:27 +#: authentication/errors.py:28 msgid "MFA unset" msgstr "多因子认证没有设定" -#: authentication/errors.py:28 +#: authentication/errors.py:29 msgid "Username does not exist" msgstr "用户名不存在" -#: authentication/errors.py:29 +#: authentication/errors.py:30 msgid "Password expired" msgstr "密码已过期" -#: authentication/errors.py:30 +#: authentication/errors.py:31 msgid "Disabled or expired" msgstr "禁用或失效" -#: authentication/errors.py:31 +#: authentication/errors.py:32 msgid "This account is inactive." msgstr "此账户已禁用" -#: authentication/errors.py:32 +#: authentication/errors.py:33 msgid "Auth backend not match" -msgstr "" +msgstr "没有匹配到认证后端" -#: authentication/errors.py:42 +#: authentication/errors.py:34 +msgid "ACL is not allowed" +msgstr "ACL 不被允许" + +#: authentication/errors.py:44 msgid "No session found, check your cookie" msgstr "会话已变更,刷新页面" -#: authentication/errors.py:44 +#: authentication/errors.py:46 #, python-brace-format msgid "" "The username or password you entered is incorrect, please enter it again. " @@ -1251,46 +1343,46 @@ msgstr "" "您输入的用户名或密码不正确,请重新输入。 您还可以尝试 {times_try} 次(账号将" "被临时 锁定 {block_time} 分钟)" -#: authentication/errors.py:50 +#: authentication/errors.py:52 msgid "" "The account has been locked (please contact admin to unlock it or try again " "after {} minutes)" msgstr "账号已被锁定(请联系管理员解锁 或 {}分钟后重试)" -#: authentication/errors.py:53 users/views/profile/otp.py:110 +#: authentication/errors.py:55 users/views/profile/otp.py:110 #: users/views/profile/otp.py:149 users/views/profile/otp.py:169 msgid "MFA code invalid, or ntp sync server time" msgstr "MFA验证码不正确,或者服务器端时间不对" -#: authentication/errors.py:55 +#: authentication/errors.py:57 msgid "MFA required" msgstr "需要多因子认证" -#: authentication/errors.py:56 +#: authentication/errors.py:58 msgid "MFA not set, please set it first" msgstr "多因子认证没有设置,请先完成设置" -#: authentication/errors.py:57 +#: authentication/errors.py:59 msgid "Login confirm required" msgstr "需要登录复核" -#: authentication/errors.py:58 +#: authentication/errors.py:60 msgid "Wait login confirm ticket for accept" msgstr "等待登录复核处理" -#: authentication/errors.py:59 +#: authentication/errors.py:61 msgid "Login confirm ticket was {}" msgstr "登录复核 {}" -#: authentication/errors.py:215 +#: authentication/errors.py:217 msgid "SSO auth closed" msgstr "SSO 认证关闭了" -#: authentication/errors.py:220 authentication/views/login.py:232 +#: authentication/errors.py:222 authentication/views/login.py:232 msgid "Your password is too simple, please change it for security" msgstr "你的密码过于简单,为了安全,请修改" -#: authentication/errors.py:229 authentication/views/login.py:247 +#: authentication/errors.py:231 authentication/views/login.py:247 msgid "Your password has expired, please reset before logging in" msgstr "您的密码已过期,先修改再登录" @@ -1303,27 +1395,10 @@ msgstr "{} 天内自动登录" msgid "MFA code" msgstr "多因子认证验证码" -#: authentication/models.py:20 -#: authentication/templates/authentication/_access_key_modal.html:32 -#: perms/models/base.py:52 users/templates/users/_select_user_modal.html:18 -#: users/templates/users/user_detail.html:132 -#: users/templates/users/user_profile.html:63 -msgid "Active" -msgstr "激活中" - #: authentication/models.py:40 msgid "Private Token" msgstr "SSH密钥" -#: authentication/models.py:45 users/templates/users/user_detail.html:258 -msgid "Reviewers" -msgstr "审批人" - -#: authentication/models.py:71 tickets/const.py:9 -#: users/templates/users/user_detail.html:250 -msgid "Login confirm" -msgstr "登录复核" - #: authentication/models.py:94 msgid "Expired" msgstr "过期时间" @@ -1375,7 +1450,7 @@ msgstr "删除成功" #: authentication/templates/authentication/_access_key_modal.html:155 #: authentication/templates/authentication/_mfa_confirm_modal.html:53 -#: templates/_modal.html:22 tickets/const.py:18 +#: templates/_modal.html:22 tickets/const.py:19 msgid "Close" msgstr "关闭" @@ -3161,19 +3236,15 @@ msgstr "申请资产" msgid "Apply for application" msgstr "申请应用" -#: tickets/const.py:15 tickets/const.py:22 +#: tickets/const.py:16 tickets/const.py:23 msgid "Open" msgstr "打开" -#: tickets/const.py:16 +#: tickets/const.py:17 msgid "Approve" msgstr "同意" -#: tickets/const.py:17 -msgid "Reject" -msgstr "拒绝" - -#: tickets/const.py:23 +#: tickets/const.py:24 msgid "Closed" msgstr "关闭" @@ -3288,17 +3359,29 @@ msgstr "工单申请信息" msgid "Ticket approved info" msgstr "工单批准信息" +#: tickets/handler/login_asset_confirm.py:16 +msgid "Applied login user" +msgstr "申请登录的用户" + +#: tickets/handler/login_asset_confirm.py:17 +msgid "Applied login asset" +msgstr "申请登录的资产" + +#: tickets/handler/login_asset_confirm.py:18 +msgid "Applied login system user" +msgstr "申请登录的系统用户" + #: tickets/handler/login_confirm.py:16 msgid "Applied login IP" -msgstr "申请的登录IP" +msgstr "申请登录的IP" #: tickets/handler/login_confirm.py:17 msgid "Applied login city" -msgstr "申请的登录城市" +msgstr "申请登录的城市" #: tickets/handler/login_confirm.py:18 msgid "Applied login datetime" -msgstr "申请的登录日期" +msgstr "申请登录的日期" #: tickets/models/comment.py:19 msgid "User display name" @@ -3421,6 +3504,18 @@ msgstr "在组织 `{}` 下没有发现 `资产`" msgid "Created by ticket ({}-{})" msgstr "通过工单创建 ({}-{})" +#: tickets/serializers/ticket/meta/ticket_type/login_asset_confirm.py:13 +msgid "Login user" +msgstr "登录用户" + +#: tickets/serializers/ticket/meta/ticket_type/login_asset_confirm.py:14 +msgid "Login asset" +msgstr "登录资产" + +#: tickets/serializers/ticket/meta/ticket_type/login_asset_confirm.py:16 +msgid "Login system user" +msgstr "登录系统用户" + #: tickets/serializers/ticket/meta/ticket_type/login_confirm.py:20 msgid "Login datetime" msgstr "登录日期" @@ -3439,10 +3534,6 @@ msgid "" "request url (`{}`)" msgstr "提交数据中的类型 (`{}`) 与请求URL地址中的类型 (`{}`) 不一致" -#: tickets/serializers/ticket/ticket.py:109 -msgid "The organization `{}` does not exist" -msgstr "组织 `{}` 不存在" - #: tickets/serializers/ticket/ticket.py:120 msgid "None of the assignees belong to Organization `{}` admins" msgstr "所有受理人都不属于组织 `{}` 下的管理员" @@ -4764,95 +4855,95 @@ msgstr "实例" #: xpack/plugins/cloud/providers/aws_international.py:17 msgid "China (Beijing)" -msgstr "" +msgstr "中国(北京)" #: xpack/plugins/cloud/providers/aws_international.py:18 msgid "China (Ningxia)" -msgstr "" +msgstr "中国(宁夏)" #: xpack/plugins/cloud/providers/aws_international.py:21 msgid "US East (Ohio)" -msgstr "" +msgstr "美国东部(俄亥俄州)" #: xpack/plugins/cloud/providers/aws_international.py:22 msgid "US East (N. Virginia)" -msgstr "" +msgstr "美国东部(弗吉尼亚北部)" #: xpack/plugins/cloud/providers/aws_international.py:23 msgid "US West (N. California)" -msgstr "" +msgstr "美国西部(加利福尼亚北部)" #: xpack/plugins/cloud/providers/aws_international.py:24 msgid "US West (Oregon)" -msgstr "" +msgstr "美国西部(俄勒冈)" #: xpack/plugins/cloud/providers/aws_international.py:25 msgid "Africa (Cape Town)" -msgstr "" +msgstr "非洲(开普敦)" #: xpack/plugins/cloud/providers/aws_international.py:26 msgid "Asia Pacific (Hong Kong)" -msgstr "亚太-香港" +msgstr "亚太地区(香港)" #: xpack/plugins/cloud/providers/aws_international.py:27 msgid "Asia Pacific (Mumbai)" -msgstr "" +msgstr "亚太地区(孟买)" #: xpack/plugins/cloud/providers/aws_international.py:28 msgid "Asia Pacific (Osaka-Local)" -msgstr "" +msgstr "亚太区域(大阪当地)" #: xpack/plugins/cloud/providers/aws_international.py:29 msgid "Asia Pacific (Seoul)" -msgstr "" +msgstr "亚太区域(首尔)" #: xpack/plugins/cloud/providers/aws_international.py:30 msgid "Asia Pacific (Singapore)" -msgstr "亚太-新加坡" +msgstr "亚太区域(新加坡)" #: xpack/plugins/cloud/providers/aws_international.py:31 msgid "Asia Pacific (Sydney)" -msgstr "" +msgstr "亚太区域(悉尼)" #: xpack/plugins/cloud/providers/aws_international.py:32 msgid "Asia Pacific (Tokyo)" -msgstr "" +msgstr "亚太区域(东京)" #: xpack/plugins/cloud/providers/aws_international.py:33 msgid "Canada (Central)" -msgstr "" +msgstr "加拿大(中部)" #: xpack/plugins/cloud/providers/aws_international.py:34 msgid "Europe (Frankfurt)" -msgstr "" +msgstr "欧洲(法兰克福)" #: xpack/plugins/cloud/providers/aws_international.py:35 msgid "Europe (Ireland)" -msgstr "" +msgstr "欧洲(爱尔兰)" #: xpack/plugins/cloud/providers/aws_international.py:36 msgid "Europe (London)" -msgstr "" +msgstr "欧洲(伦敦)" #: xpack/plugins/cloud/providers/aws_international.py:37 msgid "Europe (Milan)" -msgstr "" +msgstr "欧洲(米兰)" #: xpack/plugins/cloud/providers/aws_international.py:38 msgid "Europe (Paris)" -msgstr "" +msgstr "欧洲(巴黎)" #: xpack/plugins/cloud/providers/aws_international.py:39 msgid "Europe (Stockholm)" -msgstr "" +msgstr "欧洲(斯德哥尔摩)" #: xpack/plugins/cloud/providers/aws_international.py:40 msgid "Middle East (Bahrain)" -msgstr "" +msgstr "中东(巴林)" #: xpack/plugins/cloud/providers/aws_international.py:41 msgid "South America (São Paulo)" -msgstr "" +msgstr "南美洲(圣保罗)" #: xpack/plugins/cloud/providers/huaweicloud.py:35 msgid "AF-Johannesburg" @@ -4912,19 +5003,19 @@ msgstr "" #: xpack/plugins/cloud/serializers.py:28 msgid "Client ID" -msgstr "Client ID" +msgstr "" #: xpack/plugins/cloud/serializers.py:31 msgid "Client Secret" -msgstr "Client Secret" +msgstr "" #: xpack/plugins/cloud/serializers.py:34 msgid "Tenant ID" -msgstr "租户ID" +msgstr "" #: xpack/plugins/cloud/serializers.py:37 msgid "Subscription ID" -msgstr "订阅ID" +msgstr "" #: xpack/plugins/cloud/serializers.py:115 msgid "History count" @@ -5026,31 +5117,3 @@ msgstr "旗舰版" #: xpack/plugins/license/models.py:77 msgid "Community edition" msgstr "社区版" - -#~ msgid "Captcha invalid" -#~ msgstr "验证码错误" - -#~ msgid "" -#~ "Not support openssh format key, using ssh-keygen -t rsa -m pem to generate" -#~ msgstr "暂不支持OPENSSH格式的密钥,使用 ssh-keygen -t rsa -m pem生成" - -#~ msgid "Select users" -#~ msgstr "选择用户" - -#~ msgid "Paste user id_rsa.pub here." -#~ msgstr "复制用户公钥到这里" - -#~ msgid "Available" -#~ msgstr "有效" - -#~ msgid "Unavailable" -#~ msgstr "无效" - -#~ msgid "Instances" -#~ msgstr "实例" - -#~ msgid "LA-Santiago" -#~ msgstr "拉美-圣地亚哥" - -#~ msgid "Please wait while your data is being initialized" -#~ msgstr "数据正在初始化,请稍等" diff --git a/apps/orgs/utils.py b/apps/orgs/utils.py index 2eaa9e9e6..29b1f03e9 100644 --- a/apps/orgs/utils.py +++ b/apps/orgs/utils.py @@ -67,8 +67,6 @@ def get_current_org_id(): def get_current_org_id_for_serializer(): org_id = get_current_org_id() - if org_id == Organization.DEFAULT_ID: - org_id = '' return org_id diff --git a/apps/terminal/models/storage.py b/apps/terminal/models/storage.py index 3f574985c..9497ea802 100644 --- a/apps/terminal/models/storage.py +++ b/apps/terminal/models/storage.py @@ -56,7 +56,7 @@ class CommandStorage(CommonModelMixin): return storage.ping() def is_use(self): - return Terminal.objects.filter(command_storage=self.name).exists() + return Terminal.objects.filter(command_storage=self.name, is_deleted=False).exists() def get_command_queryset(self): if self.type_server: diff --git a/apps/tickets/api/ticket.py b/apps/tickets/api/ticket.py index 91a585298..071bed755 100644 --- a/apps/tickets/api/ticket.py +++ b/apps/tickets/api/ticket.py @@ -54,14 +54,14 @@ class TicketViewSet(CommonApiMixin, viewsets.ModelViewSet): def open(self, request, *args, **kwargs): return super().create(request, *args, **kwargs) - @action(detail=True, methods=[PUT], permission_classes=[IsOrgAdmin, IsAssignee, NotClosed]) + @action(detail=True, methods=[PUT], permission_classes=[IsAssignee, NotClosed]) def approve(self, request, *args, **kwargs): response = super().update(request, *args, **kwargs) instance = self.get_object() instance.approve(processor=self.request.user) return response - @action(detail=True, methods=[PUT], permission_classes=[IsOrgAdmin, IsAssignee, NotClosed]) + @action(detail=True, methods=[PUT], permission_classes=[IsAssignee, NotClosed]) def reject(self, request, *args, **kwargs): instance = self.get_object() serializer = self.get_serializer(instance) diff --git a/apps/tickets/const.py b/apps/tickets/const.py index 742d4e7d3..591ead607 100644 --- a/apps/tickets/const.py +++ b/apps/tickets/const.py @@ -9,6 +9,7 @@ class TicketTypeChoices(TextChoices): login_confirm = 'login_confirm', _("Login confirm") apply_asset = 'apply_asset', _('Apply for asset') apply_application = 'apply_application', _('Apply for application') + login_asset_confirm = 'login_asset_confirm', _('Login asset confirm') class TicketActionChoices(TextChoices): diff --git a/apps/tickets/handler/login_asset_confirm.py b/apps/tickets/handler/login_asset_confirm.py new file mode 100644 index 000000000..b967af29d --- /dev/null +++ b/apps/tickets/handler/login_asset_confirm.py @@ -0,0 +1,20 @@ +from django.utils.translation import ugettext as _ +from .base import BaseHandler + + +class Handler(BaseHandler): + + # body + def _construct_meta_body_of_open(self): + apply_login_user = self.ticket.meta.get('apply_login_user') + apply_login_asset = self.ticket.meta.get('apply_login_asset') + apply_login_system_user = self.ticket.meta.get('apply_login_system_user') + applied_body = '''{}: {}, + {}: {}, + {}: {} + '''.format( + _("Applied login user"), apply_login_user, + _("Applied login asset"), apply_login_asset, + _("Applied login system user"), apply_login_system_user, + ) + return applied_body diff --git a/apps/tickets/migrations/0008_auto_20210311_1113.py b/apps/tickets/migrations/0008_auto_20210311_1113.py new file mode 100644 index 000000000..be959ba0e --- /dev/null +++ b/apps/tickets/migrations/0008_auto_20210311_1113.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1 on 2021-03-11 03:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tickets', '0007_auto_20201224_1821'), + ] + + operations = [ + migrations.AlterField( + model_name='ticket', + name='type', + field=models.CharField(choices=[('general', 'General'), ('login_confirm', 'Login confirm'), ('apply_asset', 'Apply for asset'), ('apply_application', 'Apply for application'), ('login_asset_confirm', 'Login asset confirm')], default='general', max_length=64, verbose_name='Type'), + ), + ] diff --git a/apps/tickets/serializers/ticket/meta/meta.py b/apps/tickets/serializers/ticket/meta/meta.py index b423805f4..ee8a402dd 100644 --- a/apps/tickets/serializers/ticket/meta/meta.py +++ b/apps/tickets/serializers/ticket/meta/meta.py @@ -1,5 +1,5 @@ from tickets import const -from .ticket_type import apply_asset, apply_application, login_confirm +from .ticket_type import apply_asset, apply_application, login_confirm, login_asset_confirm __all__ = [ 'type_serializer_classes_mapping', @@ -30,5 +30,10 @@ type_serializer_classes_mapping = { 'default': login_confirm.LoginConfirmSerializer, action_open: login_confirm.ApplySerializer, action_approve: login_confirm.LoginConfirmSerializer(read_only=True), + }, + const.TicketTypeChoices.login_asset_confirm.value: { + 'default': login_asset_confirm.LoginAssetConfirmSerializer, + action_open: login_asset_confirm.ApplySerializer, + action_approve: login_asset_confirm.LoginAssetConfirmSerializer(read_only=True), } } diff --git a/apps/tickets/serializers/ticket/meta/ticket_type/login_asset_confirm.py b/apps/tickets/serializers/ticket/meta/ticket_type/login_asset_confirm.py new file mode 100644 index 000000000..2570e4792 --- /dev/null +++ b/apps/tickets/serializers/ticket/meta/ticket_type/login_asset_confirm.py @@ -0,0 +1,21 @@ + +from rest_framework import serializers +from django.utils.translation import ugettext_lazy as _ + + +__all__ = [ + 'ApplySerializer', 'LoginAssetConfirmSerializer', +] + + +class ApplySerializer(serializers.Serializer): + # 申请信息 + apply_login_user = serializers.CharField(required=True, label=_('Login user')) + apply_login_asset = serializers.CharField(required=True, label=_('Login asset')) + apply_login_system_user = serializers.CharField( + required=True, max_length=64, label=_('Login system user') + ) + + +class LoginAssetConfirmSerializer(ApplySerializer): + pass