Merge pull request #4280 from jumpserver/dev

merge: Merge to master from branch dev
pull/4357/head
BaiJiangJie 2020-07-09 15:02:47 +08:00 committed by GitHub
commit cadf42f3fa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
44 changed files with 1073 additions and 276 deletions

View File

@ -14,7 +14,7 @@ from .. import serializers
from ..tasks import (
update_asset_hardware_info_manual, test_asset_connectivity_manual
)
from ..filters import AssetByNodeFilterBackend, LabelFilterBackend
from ..filters import AssetByNodeFilterBackend, LabelFilterBackend, IpInFilterBackend
logger = get_logger(__file__)
@ -32,7 +32,7 @@ class AssetViewSet(OrgBulkModelViewSet):
model = Asset
filter_fields = (
"hostname", "ip", "systemuser__id", "admin_user__id", "platform__base",
"is_active"
"is_active", 'ip'
)
search_fields = ("hostname", "ip")
ordering_fields = ("hostname", "ip", "port", "cpu_cores")
@ -41,7 +41,7 @@ class AssetViewSet(OrgBulkModelViewSet):
'display': serializers.AssetDisplaySerializer,
}
permission_classes = (IsOrgAdminOrAppUser,)
extra_filter_backends = [AssetByNodeFilterBackend, LabelFilterBackend]
extra_filter_backends = [AssetByNodeFilterBackend, LabelFilterBackend, IpInFilterBackend]
def set_assets_node(self, assets):
if not isinstance(assets, list):

View File

@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
#
import coreapi
from rest_framework.compat import coreapi, coreschema
from rest_framework import filters
from django.db.models import Q
@ -117,3 +117,23 @@ class AssetRelatedByNodeFilterBackend(AssetByNodeFilterBackend):
def perform_query(pattern, queryset):
return queryset.filter(asset__nodes__key__regex=pattern).distinct()
class IpInFilterBackend(filters.BaseFilterBackend):
def filter_queryset(self, request, queryset, view):
ips = request.query_params.get('ips')
if not ips:
return queryset
ip_list = [i.strip() for i in ips.split(',')]
queryset = queryset.filter(ip__in=ip_list)
return queryset
def get_schema_fields(self, view):
return [
coreapi.Field(
name='ips', location='query', required=False, type='string',
schema=coreschema.String(
title='ips',
description='ip in filter'
)
)
]

View File

@ -0,0 +1,22 @@
# Generated by Django 2.2.10 on 2020-07-02 08:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0049_systemuser_sftp_root'),
]
operations = [
migrations.AlterField(
model_name='domain',
name='name',
field=models.CharField(max_length=128, verbose_name='Name'),
),
migrations.AlterUniqueTogether(
name='domain',
unique_together={('org_id', 'name')},
),
]

View File

@ -244,10 +244,6 @@ class Asset(ProtocolsMixin, NodesRelationMixin, OrgModelMixin):
def platform_base(self):
return self.platform.base
@lazyproperty
def admin_user_display(self):
return self.admin_user.name
@lazyproperty
def admin_user_username(self):
"""求可连接性时直接用用户名去取避免再查一次admin user

View File

@ -17,13 +17,14 @@ __all__ = ['Domain', 'Gateway']
class Domain(OrgModelMixin):
id = models.UUIDField(default=uuid.uuid4, primary_key=True)
name = models.CharField(max_length=128, unique=True, verbose_name=_('Name'))
name = models.CharField(max_length=128, verbose_name=_('Name'))
comment = models.TextField(blank=True, verbose_name=_('Comment'))
date_created = models.DateTimeField(auto_now_add=True, null=True,
verbose_name=_('Date created'))
class Meta:
verbose_name = _("Domain")
unique_together = [('org_id', 'name')]
def __str__(self):
return self.name

View File

@ -199,6 +199,20 @@ class FamilyMixin:
)
return child
def get_or_create_child(self, value, _id=None):
"""
:return: Node, bool (created)
"""
children = self.get_children()
exist = children.filter(value=value).exists()
if exist:
child = children.filter(value=value).first()
created = False
else:
child = self.create_child(value, _id)
created = True
return child, created
def get_next_child_key(self):
mark = self.child_mark
self.child_mark += 1

View File

@ -67,6 +67,9 @@ class AssetSerializer(BulkOrgResourceModelSerializer):
slug_field='name', queryset=Platform.objects.all(), label=_("Platform")
)
protocols = ProtocolsField(label=_('Protocols'), required=False)
domain_display = serializers.ReadOnlyField(source='domain.name')
admin_user_display = serializers.ReadOnlyField(source='admin_user.name')
"""
资产的数据结构
"""
@ -82,7 +85,7 @@ class AssetSerializer(BulkOrgResourceModelSerializer):
'created_by', 'date_created', 'hardware_info',
]
fields_fk = [
'admin_user', 'admin_user_display', 'domain', 'platform'
'admin_user', 'admin_user_display', 'domain', 'domain_display', 'platform'
]
fk_only_fields = {
'platform': ['name']

View File

@ -85,7 +85,7 @@ def test_asset_user_connectivity_util(asset_user, task_name):
raw, summary = test_user_connectivity(
task_name=task_name, asset=asset_user.asset,
username=asset_user.username, password=asset_user.password,
private_key=asset_user.private_key
private_key=asset_user.private_key_file
)
except Exception as e:
logger.warn("Failed run adhoc {}, {}".format(task_name, e))

View File

@ -31,7 +31,10 @@ def test_system_user_connectivity_util(system_user, assets, task_name):
"""
from ops.utils import update_or_create_ansible_task
hosts = clean_ansible_task_hosts(assets, system_user=system_user)
# hosts = clean_ansible_task_hosts(assets, system_user=system_user)
# TODO: 这里不传递系统用户因为clean_ansible_task_hosts会通过system_user来判断是否可以推送
# 不符合测试可连接性逻辑, 后面需要优化此逻辑
hosts = clean_ansible_task_hosts(assets)
if not hosts:
return {}
platform_hosts_map = {}

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2.10 on 2020-06-24 08:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('audits', '0008_auto_20200508_2105'),
]
operations = [
migrations.AlterField(
model_name='ftplog',
name='operate',
field=models.CharField(choices=[('Delete', 'Delete'), ('Upload', 'Upload'), ('Download', 'Download'), ('Rmdir', 'Rmdir'), ('Rename', 'Rename'), ('Mkdir', 'Mkdir'), ('Symlink', 'Symlink')], max_length=16, verbose_name='Operate'),
),
]

View File

@ -14,12 +14,30 @@ __all__ = [
class FTPLog(OrgModelMixin):
OPERATE_DELETE = 'Delete'
OPERATE_UPLOAD = 'Upload'
OPERATE_DOWNLOAD = 'Download'
OPERATE_RMDIR = 'Rmdir'
OPERATE_RENAME = 'Rename'
OPERATE_MKDIR = 'Mkdir'
OPERATE_SYMLINK = 'Symlink'
OPERATE_CHOICES = (
(OPERATE_DELETE, _('Delete')),
(OPERATE_UPLOAD, _('Upload')),
(OPERATE_DOWNLOAD, _('Download')),
(OPERATE_RMDIR, _('Rmdir')),
(OPERATE_RENAME, _('Rename')),
(OPERATE_MKDIR, _('Mkdir')),
(OPERATE_SYMLINK, _('Symlink'))
)
id = models.UUIDField(default=uuid.uuid4, primary_key=True)
user = models.CharField(max_length=128, verbose_name=_('User'))
remote_addr = models.CharField(max_length=128, verbose_name=_("Remote addr"), blank=True, null=True)
asset = models.CharField(max_length=1024, verbose_name=_("Asset"))
system_user = models.CharField(max_length=128, verbose_name=_("System user"))
operate = models.CharField(max_length=16, verbose_name=_("Operate"))
operate = models.CharField(max_length=16, verbose_name=_("Operate"), choices=OPERATE_CHOICES)
filename = models.CharField(max_length=1024, verbose_name=_("Filename"))
is_success = models.BooleanField(default=True, verbose_name=_("Success"))
date_start = models.DateTimeField(auto_now_add=True, verbose_name=_('Date start'))

View File

@ -12,12 +12,13 @@ from . import models
class FTPLogSerializer(serializers.ModelSerializer):
operate_display = serializers.ReadOnlyField(source='get_operate_display')
class Meta:
model = models.FTPLog
fields = (
'id', 'user', 'remote_addr', 'asset', 'system_user',
'operate', 'filename', 'is_success', 'date_start'
'operate', 'filename', 'is_success', 'date_start', 'operate_display'
)

View File

@ -10,6 +10,7 @@ from users.utils import (
)
reason_password_failed = 'password_failed'
reason_password_decrypt_failed = 'password_decrypt_failed'
reason_mfa_failed = 'mfa_failed'
reason_mfa_unset = 'mfa_unset'
reason_user_not_exist = 'user_not_exist'
@ -19,6 +20,7 @@ reason_user_inactive = 'user_inactive'
reason_choices = {
reason_password_failed: _('Username/password check failed'),
reason_password_decrypt_failed: _('Password decrypt failed'),
reason_mfa_failed: _('MFA failed'),
reason_mfa_unset: _('MFA unset'),
reason_user_not_exist: _("Username does not exist"),

View File

@ -10,7 +10,7 @@ class UserLoginForm(forms.Form):
username = forms.CharField(label=_('Username'), max_length=100)
password = forms.CharField(
label=_('Password'), widget=forms.PasswordInput,
max_length=128, strip=False
max_length=1024, strip=False
)
def confirm_login_allowed(self, user):

View File

@ -7,7 +7,7 @@
{% endblock %}
{% block content %}
<form class="m-t" role="form" method="post" action="">
<form id="form" class="m-t" role="form" method="post" action="">
{% csrf_token %}
{% if form.non_field_errors %}
<div style="line-height: 17px;">
@ -26,7 +26,7 @@
{% endif %}
</div>
<div class="form-group">
<input type="password" class="form-control" name="{{ form.password.html_name }}" placeholder="{% trans 'Password' %}" required="">
<input type="password" class="form-control" id="password" name="{{ form.password.html_name }}" placeholder="{% trans 'Password' %}" required="">
{% if form.errors.password %}
<div class="help-block field-error">
<p class="red-fonts">{{ form.errors.password.as_text }}</p>
@ -36,7 +36,7 @@
<div>
{{ form.captcha }}
</div>
<button type="submit" class="btn btn-primary block full-width m-b">{% trans 'Login' %}</button>
<button type="submit" class="btn btn-primary block full-width m-b" onclick="doLogin();return false;">{% trans 'Login' %}</button>
{% if demo_mode %}
<p class="text-muted font-bold" style="color: red">
@ -64,4 +64,20 @@
{% endif %}
</form>
<script type="text/javascript" src="/static/js/plugins/jsencrypt/jsencrypt.min.js"></script>
<script>
function encryptLoginPassword(password, rsaPublicKey){
var jsencrypt = new JSEncrypt(); //加密对象
jsencrypt.setPublicKey(rsaPublicKey); // 设置密钥
return jsencrypt.encrypt(password); //加密
}
function doLogin() {
//公钥加密
var rsaPublicKey = "{{ rsa_public_key }}"
var password =$('#password').val(); //明文密码
var passwordEncrypted = encryptLoginPassword(password, rsaPublicKey)
$('#password').val(passwordEncrypted); //返回给密码输入input
$('#form').submit();//post提交
}
</script>
{% endblock %}

View File

@ -98,7 +98,7 @@
{% endif %}
</div>
<div class="form-group">
<input type="password" class="form-control" name="{{ form.password.html_name }}" placeholder="{% trans 'Password' %}" required="">
<input type="password" class="form-control" id="password" name="{{ form.password.html_name }}" placeholder="{% trans 'Password' %}" required="">
{% if form.errors.password %}
<div class="help-block field-error">
<p class="red-fonts">{{ form.errors.password.as_text }}</p>
@ -109,7 +109,7 @@
{{ form.captcha }}
</div>
<div class="form-group" style="margin-top: 10px">
<button type="submit" class="btn btn-transparent">{% trans 'Login' %}</button>
<button type="submit" class="btn btn-transparent" onclick="doLogin();return false;">{% trans 'Login' %}</button>
</div>
<div style="text-align: center">
<a href="{% url 'authentication:forgot-password' %}">
@ -127,4 +127,21 @@
</div>
</body>
<script type="text/javascript" src="/static/js/plugins/jsencrypt/jsencrypt.min.js"></script>
<script>
function encryptLoginPassword(password, rsaPublicKey){
var jsencrypt = new JSEncrypt(); //加密对象
jsencrypt.setPublicKey(rsaPublicKey); // 设置密钥
return jsencrypt.encrypt(password); //加密
}
function doLogin() {
//公钥加密
var rsaPublicKey = "{{ rsa_public_key }}"
var password =$('#password').val(); //明文密码
var passwordEncrypted = encryptLoginPassword(password, rsaPublicKey)
$('#password').val(passwordEncrypted); //返回给密码输入input
$('#contact-form').submit();//post提交
}
</script>
</html>

View File

@ -1 +1,15 @@
from .utils import gen_key_pair, rsa_decrypt, rsa_encrypt
def test_rsa_encrypt_decrypt(message='test-password-$%^&*'):
""" 测试加密/解密 """
print('Need to encrypt message: {}'.format(message))
rsa_private_key, rsa_public_key = gen_key_pair()
print('RSA public key: \n{}'.format(rsa_public_key))
print('RSA private key: \n{}'.format(rsa_private_key))
message_encrypted = rsa_encrypt(message, rsa_public_key)
print('Encrypted message: {}'.format(message_encrypted))
message_decrypted = rsa_decrypt(message_encrypted, rsa_private_key)
print('Decrypted message: {}'.format(message_decrypted))

View File

@ -1,9 +1,47 @@
# -*- coding: utf-8 -*-
#
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__)
def gen_key_pair():
""" 生成加密key
用于登录页面提交用户名/密码时对密码进行加密前端/解密后端
"""
random_generator = Random.new().read
rsa = RSA.generate(1024, random_generator)
rsa_private_key = rsa.exportKey().decode()
rsa_public_key = rsa.publickey().exportKey().decode()
return rsa_private_key, rsa_public_key
def rsa_encrypt(message, rsa_public_key):
""" 加密登录密码 """
key = RSA.importKey(rsa_public_key)
cipher = PKCS1_v1_5.new(key)
cipher_text = base64.b64encode(cipher.encrypt(message.encode())).decode()
return cipher_text
def rsa_decrypt(cipher_text, rsa_private_key=None):
""" 解密登录密码 """
if rsa_private_key is None:
# rsa_private_key 为 None可以能是API请求认证不需要解密
return cipher_text
key = RSA.importKey(rsa_private_key)
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)
@ -11,6 +49,16 @@ def check_user_valid(**kwargs):
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:

View File

@ -22,7 +22,7 @@ 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
from .. import forms, mixins, errors, utils
__all__ = [
@ -108,9 +108,13 @@ class UserLoginView(mixins.AuthMixin, FormView):
return self.form_class
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
context = {
'demo_mode': os.environ.get("DEMO_MODE"),
'AUTH_OPENID': settings.AUTH_OPENID,
'rsa_public_key': rsa_public_key.replace('\n', '\\n')
}
kwargs.update(context)
return super().get_context_data(**kwargs)

View File

View File

@ -0,0 +1,28 @@
from django.db.models import Aggregate
class GroupConcat(Aggregate):
function = 'GROUP_CONCAT'
template = '%(function)s(%(distinct)s %(expressions)s %(order_by)s %(separator))'
allow_distinct = False
def __init__(self, expression, distinct=False, order_by=None, separator=',', **extra):
order_by_clause = ''
if order_by is not None:
order = 'ASC'
prefix, body = order_by[1], order_by[1:]
if prefix == '-':
order = 'DESC'
elif prefix == '+':
pass
else:
body = order_by
order_by_clause = f'ORDER BY {body} {order}'
super().__init__(
expression,
distinct='DISTINCT' if distinct else '',
order_by=order_by_clause,
separator=f'SEPARATOR {separator}',
**extra
)

11
apps/common/drf/api.py Normal file
View File

@ -0,0 +1,11 @@
from rest_framework.viewsets import GenericViewSet, ModelViewSet
from ..mixins.api import SerializerMixin2, QuerySetMixin, ExtraFilterFieldsMixin
class JmsGenericViewSet(SerializerMixin2, QuerySetMixin, ExtraFilterFieldsMixin, GenericViewSet):
pass
class JMSModelViewSet(SerializerMixin2, QuerySetMixin, ExtraFilterFieldsMixin, ModelViewSet):
pass

View File

@ -6,18 +6,27 @@ import chardet
import codecs
import unicodecsv
from django.utils.translation import ugettext as _
from rest_framework.parsers import BaseParser
from rest_framework.exceptions import ParseError
from rest_framework.exceptions import ParseError, APIException
from rest_framework import status
from common.utils import get_logger
logger = get_logger(__file__)
class CsvDataTooBig(APIException):
status_code = status.HTTP_400_BAD_REQUEST
default_code = 'csv_data_too_big'
default_detail = _('The max size of CSV is %d bytes')
class JMSCSVParser(BaseParser):
"""
Parses CSV file to serializer data
"""
CSV_UPLOAD_MAX_SIZE = 1024 * 1024 * 10
media_type = 'text/csv'
@ -46,23 +55,31 @@ class JMSCSVParser(BaseParser):
return fields_map
@staticmethod
def _process_row(row):
def _replace_chinese_quot(str_):
trans_table = str.maketrans({
'': '"',
'': '"',
'': '"',
'': '"',
'\'': '"'
})
return str_.translate(trans_table)
@classmethod
def _process_row(cls, row):
"""
构建json数据前的行处理
"""
_row = []
for col in row:
# 列表转换
if isinstance(col, str) and col.find("[") != -1 and col.find("]") != -1:
# 替换中文格式引号
col = col.replace("", '"').replace("", '"').\
replace("", '"').replace('', '"').replace("'", '"')
if isinstance(col, str) and col.startswith('[') and col.endswith(']'):
col = cls._replace_chinese_quot(col)
col = json.loads(col)
# 字典转换
if isinstance(col, str) and col.find("{") != -1 and col.find("}") != -1:
# 替换中文格式引号
col = col.replace("", '"').replace("", '"'). \
replace("", '"').replace('', '"').replace("'", '"')
if isinstance(col, str) and col.startswith("{") and col.endswith("}"):
col = cls._replace_chinese_quot(col)
col = json.loads(col)
_row.append(col)
return _row
@ -82,11 +99,19 @@ class JMSCSVParser(BaseParser):
def parse(self, stream, media_type=None, parser_context=None):
parser_context = parser_context or {}
try:
serializer = parser_context["view"].get_serializer()
view = parser_context['view']
meta = view.request.META
serializer = view.get_serializer()
except Exception as e:
logger.debug(e, exc_info=True)
raise ParseError('The resource does not support imports!')
content_length = int(meta.get('CONTENT_LENGTH', meta.get('HTTP_CONTENT_LENGTH', 0)))
if content_length > self.CSV_UPLOAD_MAX_SIZE:
msg = CsvDataTooBig.default_detail % self.CSV_UPLOAD_MAX_SIZE
logger.error(msg)
raise CsvDataTooBig(msg)
try:
stream_data = stream.read()
stream_data = stream_data.strip(codecs.BOM_UTF8)

View File

@ -0,0 +1,5 @@
from rest_framework.serializers import Serializer
class EmptySerializer(Serializer):
pass

View File

@ -1,3 +1,7 @@
# -*- coding: utf-8 -*-
#
from rest_framework.exceptions import APIException
class JMSException(APIException):
pass

View File

@ -4,6 +4,7 @@ import time
from hashlib import md5
from threading import Thread
from collections import defaultdict
from itertools import chain
from django.db.models.signals import m2m_changed
from django.core.cache import cache
@ -15,8 +16,8 @@ from common.drf.filters import IDSpmFilter, CustomFilter, IDInFilter
from ..utils import lazyproperty
__all__ = [
"JSONResponseMixin", "CommonApiMixin",
'AsyncApiMixin', 'RelationMixin'
'JSONResponseMixin', 'CommonApiMixin', 'AsyncApiMixin', 'RelationMixin',
'SerializerMixin2', 'QuerySetMixin', 'ExtraFilterFieldsMixin'
]
@ -54,9 +55,10 @@ class ExtraFilterFieldsMixin:
def get_filter_backends(self):
if self.filter_backends != self.__class__.filter_backends:
return self.filter_backends
backends = list(self.filter_backends) + \
list(self.default_added_filters) + \
list(self.extra_filter_backends)
backends = list(chain(
self.filter_backends,
self.default_added_filters,
self.extra_filter_backends))
return backends
def filter_queryset(self, queryset):
@ -233,3 +235,32 @@ class RelationMixin:
def perform_create(self, serializer):
instance = serializer.save()
self.send_post_add_signal(instance)
class SerializerMixin2:
serializer_classes = {}
def get_serializer_class(self):
if self.serializer_classes:
serializer_class = self.serializer_classes.get(
self.action, self.serializer_classes.get('default')
)
if isinstance(serializer_class, dict):
serializer_class = serializer_class.get(
self.request.method.lower, serializer_class.get('default')
)
assert serializer_class, '`serializer_classes` config error'
return serializer_class
return super().get_serializer_class()
class QuerySetMixin:
def get_queryset(self):
queryset = super().get_queryset()
serializer_class = self.get_serializer_class()
if serializer_class and hasattr(serializer_class, 'setup_eager_loading'):
queryset = serializer_class.setup_eager_loading(queryset)
return queryset

View File

@ -226,6 +226,7 @@ class Config(dict):
'TERMINAL_COMMAND_STORAGE': {},
'SECURITY_MFA_AUTH': False,
'SECURITY_COMMAND_EXECUTION': True,
'SECURITY_SERVICE_ACCOUNT_REGISTRATION': True,
'SECURITY_VIEW_AUTH_NEED_MFA': True,
'SECURITY_LOGIN_LIMIT_COUNT': 7,

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -22,6 +22,8 @@ logger = get_logger(__file__)
class OrgViewSet(BulkModelViewSet):
filter_fields = ('name',)
search_fields = ('name', 'comment')
queryset = Organization.objects.all()
serializer_class = OrgSerializer
permission_classes = (IsSuperUserOrAppUser,)

File diff suppressed because one or more lines are too long

View File

@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
#
from .ticket import *
from .request_asset_perm import *

View File

@ -0,0 +1,137 @@
from collections import namedtuple
from django.db.transaction import atomic
from django.db.models import F
from django.utils.translation import ugettext_lazy as _
from rest_framework.decorators import action
from rest_framework.response import Response
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.drf.serializers import EmptySerializer
from perms.models.asset_permission import AssetPermission, Asset
from assets.models.user import SystemUser
from ..exceptions import (
ConfirmedAssetsChanged, ConfirmedSystemUserChanged,
TicketClosed, TicketActionYet, NotHaveConfirmedAssets,
NotHaveConfirmedSystemUser
)
from .. import serializers
from ..models import Ticket
from ..permissions import IsAssignee
class RequestAssetPermTicketViewSet(JMSModelViewSet):
queryset = Ticket.objects.filter(type=Ticket.TYPE_REQUEST_ASSET_PERM)
serializer_classes = {
'default': serializers.RequestAssetPermTicketSerializer,
'approve': EmptySerializer,
'reject': EmptySerializer,
'assignees': serializers.OrgAssigneeSerializer,
}
permission_classes = (IsValidUser,)
filter_fields = ['status', 'title', 'action', 'user_display']
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.action == action:
action_display = dict(instance.ACTION_CHOICES).get(action)
raise TicketActionYet(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'))
user = request.user
superusers = User.objects.filter(role=User.ROLE_ADMIN)
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')
)
for user in admins_with_org:
org_id = user.org_id
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))
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)
@action(detail=True, methods=[POST], permission_classes=[IsAssignee, IsValidUser])
def reject(self, request, *args, **kwargs):
instance = self.get_object()
action = instance.ACTION_REJECT
self._check_can_set_action(instance, action)
instance.perform_action(action, request.user)
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
self._check_can_set_action(instance, action)
meta = instance.meta
confirmed_assets = meta.get('confirmed_assets', [])
assets = list(Asset.objects.filter(id__in=confirmed_assets))
if not assets:
raise NotHaveConfirmedAssets(detail=_('Confirm assets first'))
if len(assets) != len(confirmed_assets):
raise ConfirmedAssetsChanged(detail=_('Confirmed assets changed'))
confirmed_system_user = meta.get('confirmed_system_user')
if not confirmed_system_user:
raise NotHaveConfirmedSystemUser(detail=_('Confirm system-user first'))
system_user = get_object_or_none(SystemUser, id=confirmed_system_user)
if system_user is None:
raise ConfirmedSystemUserChanged(detail=_('Confirmed system-user changed'))
self._create_asset_permission(instance, assets, system_user)
return Response({'detail': _('Succeed')})
def _create_asset_permission(self, instance: Ticket, assets, system_user):
meta = instance.meta
request = self.request
ap_kwargs = {
'name': meta.get('name', ''),
'created_by': self.request.user.username,
'comment': _('{} request assets, approved by {}').format(instance.user_display,
instance.assignee_display)
}
date_start = meta.get('date_start')
date_expired = 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)
return ap

View File

@ -0,0 +1,25 @@
from common.exceptions import JMSException
class NotHaveConfirmedAssets(JMSException):
pass
class ConfirmedAssetsChanged(JMSException):
pass
class NotHaveConfirmedSystemUser(JMSException):
pass
class ConfirmedSystemUserChanged(JMSException):
pass
class TicketClosed(JMSException):
pass
class TicketActionYet(JMSException):
pass

View File

@ -20,9 +20,11 @@ class Ticket(CommonModelMixin):
)
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_LOGIN_CONFIRM, _("Login confirm")),
(TYPE_REQUEST_ASSET_PERM, _('Request asset permission'))
)
ACTION_APPROVE = 'approve'
ACTION_REJECT = 'reject'

View File

@ -4,3 +4,6 @@
from rest_framework.permissions import BasePermission
class IsAssignee(BasePermission):
def has_object_permission(self, request, view, obj):
return obj.is_assignee(request.user)

View File

@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
#
from .ticket import *
from .request_asset_perm import *

View File

@ -0,0 +1,141 @@
from rest_framework import serializers
from django.utils.translation import ugettext_lazy as _
from django.urls import reverse
from django.db.models import Q
from users.models.user import User
from ..models import Ticket
class RequestAssetPermTicketSerializer(serializers.ModelSerializer):
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,
allow_blank=True, label=_('Hostname'))
system_user = serializers.CharField(max_length=256, source='meta.system_user', default='',
allow_blank=True, label=_('System user'))
date_start = serializers.DateTimeField(source='meta.date_start', allow_null=True,
required=False, label=_('Date start'))
date_expired = serializers.DateTimeField(source='meta.date_expired', allow_null=True,
required=False, label=_('Date expired'))
confirmed_assets = serializers.ListField(child=serializers.UUIDField(),
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,
label=_('Confirmed system user'))
assets_waitlist_url = serializers.SerializerMethodField()
system_user_waitlist_url = serializers.SerializerMethodField()
class Meta:
model = Ticket
mini_fields = ['id', 'title']
small_fields = [
'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'
]
m2m_fields = [
'user', 'user_display', 'assignees', 'assignees_display',
'assignee', 'assignee_display'
]
fields = mini_fields + small_fields + m2m_fields
read_only_fields = [
'user_display', 'assignees_display', 'type', 'user', 'status',
'date_created', 'date_updated', 'action', 'id', 'assignee',
'assignee_display',
]
extra_kwargs = {
'status': {'label': _('Status')},
'action': {'label': _('Action')},
'user_display': {'label': _('User')}
}
def validate_assignees(self, assignees):
user = self.context['request'].user
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()
if count != len(assignees):
raise serializers.ValidationError(_('Must be organization admin or superuser'))
return assignees
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)
def get_assets_waitlist_url(self, instance: Ticket):
if not self._is_assignee(instance):
return None
asset_api = reverse('api-assets:asset-list')
query = ''
meta = instance.meta
ips = meta.get('ips', [])
hostname = meta.get('hostname')
if ips:
query = '?ips=%s' % ','.join(ips)
elif hostname:
query = '?search=%s' % hostname
return asset_api + query
def create(self, validated_data):
validated_data['type'] = self.Meta.model.TYPE_REQUEST_ASSET_PERM
validated_data['user'] = self.context['request'].user
self._pop_confirmed_fields()
return super().create(validated_data)
def save(self, **kwargs):
meta = self.validated_data.get('meta', {})
date_start = meta.get('date_start')
if date_start:
meta['date_start'] = date_start.strftime('%Y-%m-%d %H:%M:%S%z')
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)
def update(self, instance, validated_data):
new_meta = validated_data['meta']
if not self._is_assignee(instance):
self._pop_confirmed_fields()
old_meta = instance.meta
meta = {}
meta.update(old_meta)
meta.update(new_meta)
validated_data['meta'] = meta
return super().update(instance, validated_data)
def _pop_confirmed_fields(self):
meta = self.validated_data['meta']
meta.pop('confirmed_assets', None)
meta.pop('confirmed_system_user', None)
def _is_assignee(self, obj: Ticket):
user = self.context['request'].user
return obj.is_assignee(user)
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)

View File

@ -7,6 +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', api.TicketViewSet, 'ticket')
router.register('tickets/(?P<ticket_id>[0-9a-zA-Z\-]{36})/comments', api.TicketCommentViewSet, 'ticket-comment')

View File

@ -3,6 +3,7 @@ import uuid
from rest_framework import generics
from rest_framework.permissions import IsAuthenticated
from django.conf import settings
from common.permissions import (
IsCurrentUserOrReadOnly
@ -64,8 +65,9 @@ class UserProfileApi(generics.RetrieveUpdateAPIView):
return self.request.user
def retrieve(self, request, *args, **kwargs):
age = request.session.get_expiry_age()
request.session.set_expiry(age)
if not settings.SESSION_EXPIRE_AT_BROWSER_CLOSE:
age = request.session.get_expiry_age()
request.session.set_expiry(age)
return super().retrieve(request, *args, **kwargs)

View File

@ -18,6 +18,7 @@ from ..serializers import UserSerializer, UserRetrieveSerializer
from .mixins import UserQuerysetMixin
from ..models import User
from ..signals import post_user_create
from ..filters import OrgRoleUserFilterBackend
logger = get_logger(__name__)
@ -35,6 +36,7 @@ class UserViewSet(CommonApiMixin, UserQuerysetMixin, BulkModelViewSet):
'default': UserSerializer,
'retrieve': UserRetrieveSerializer
}
extra_filter_backends = [OrgRoleUserFilterBackend]
def get_queryset(self):
return super().get_queryset().prefetch_related('groups')

32
apps/users/filters.py Normal file
View File

@ -0,0 +1,32 @@
from rest_framework.compat import coreapi, coreschema
from rest_framework import filters
from users.models.user import User
from orgs.utils import current_org
class OrgRoleUserFilterBackend(filters.BaseFilterBackend):
def filter_queryset(self, request, queryset, view):
org_role = request.query_params.get('org_role')
if not org_role:
return queryset
if org_role == 'admins':
return queryset & (current_org.get_org_admins() | User.objects.filter(role=User.ROLE_ADMIN))
elif org_role == 'auditors':
return queryset & current_org.get_org_auditors()
elif org_role == 'users':
return queryset & current_org.get_org_users()
elif org_role == 'members':
return queryset & current_org.get_org_members()
def get_schema_fields(self, view):
return [
coreapi.Field(
name='org_role', location='query', required=False, type='string',
schema=coreschema.String(
title='Organization role users',
description='Organization role users can be {admins|auditors|users|members}'
)
)
]

View File

@ -42,14 +42,7 @@ class UserGroupSerializer(BulkOrgResourceModelSerializer):
def set_fields_queryset(self):
users_field = self.fields.get('users')
if users_field:
users_field.child_relation.queryset = utils.get_current_org_members(exclude=('Auditor',))
def validate_users(self, users):
for user in users:
if user.is_super_auditor:
msg = _('Auditors cannot be join in the user group')
raise serializers.ValidationError(msg)
return users
users_field.child_relation.queryset = utils.get_current_org_members()
@classmethod
def setup_eager_loading(cls, queryset):

View File

@ -320,3 +320,9 @@ class UserUpdatePublicKeySerializer(serializers.ModelSerializer):
new_public_key = self.validated_data.get('public_key')
instance.set_public_key(new_public_key)
return instance
class MiniUserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ['id', 'name', 'username']