diff --git a/apps/accounts/models/account.py b/apps/accounts/models/account.py index 64b4e01a3..d4aeabfa7 100644 --- a/apps/accounts/models/account.py +++ b/apps/accounts/models/account.py @@ -131,9 +131,26 @@ class Account(AbsConnectivity, LabeledMixin, BaseAccount, JSONFilterMixin): @lazyproperty def alias(self): + """ + 别称,因为有虚拟账号,@INPUT @MANUAL @USER, 否则为 id + """ if self.username.startswith('@'): return self.username - return self.name + return self.id + + @lazyproperty + def ad_domain(self): + if self.username.startswith('@'): + return None + if self.platform.category == 'ad': + return self.asset.ad.domain_name + return None + + @lazyproperty + def full_username(self): + if self.ad_domain: + return '{}@{}'.format(self.username, self.ad_domain) + return self.username @lazyproperty def has_secret(self): diff --git a/apps/accounts/serializers/account/account.py b/apps/accounts/serializers/account/account.py index 78066c846..28c160d22 100644 --- a/apps/accounts/serializers/account/account.py +++ b/apps/accounts/serializers/account/account.py @@ -241,7 +241,7 @@ class AccountSerializer(AccountCreateUpdateSerializerMixin, BaseAccountSerialize 'date_change_secret', 'change_secret_status' ] fields = BaseAccountSerializer.Meta.fields + [ - 'su_from', 'asset', 'version', + 'su_from', 'asset', 'version', "ad_domain", 'source', 'source_id', 'secret_reset', ] + AccountCreateUpdateSerializerMixin.Meta.fields + automation_fields read_only_fields = BaseAccountSerializer.Meta.read_only_fields + automation_fields diff --git a/apps/assets/api/asset/__init__.py b/apps/assets/api/asset/__init__.py index 75c314df7..a7db63855 100644 --- a/apps/assets/api/asset/__init__.py +++ b/apps/assets/api/asset/__init__.py @@ -7,3 +7,4 @@ from .gpt import * from .host import * from .permission import * from .web import * +from .ad import * diff --git a/apps/assets/api/asset/ad.py b/apps/assets/api/asset/ad.py new file mode 100644 index 000000000..301d4f440 --- /dev/null +++ b/apps/assets/api/asset/ad.py @@ -0,0 +1,16 @@ +from assets.models import AD, Asset +from assets.serializers import ADSerializer + +from .asset import AssetViewSet + +__all__ = ['ADViewSet'] + + +class ADViewSet(AssetViewSet): + model = AD + perm_model = Asset + + def get_serializer_classes(self): + serializer_classes = super().get_serializer_classes() + serializer_classes['default'] = ADSerializer + return serializer_classes diff --git a/apps/assets/api/asset/asset.py b/apps/assets/api/asset/asset.py index d31c16be5..9399af87a 100644 --- a/apps/assets/api/asset/asset.py +++ b/apps/assets/api/asset/asset.py @@ -11,6 +11,7 @@ from rest_framework.decorators import action from rest_framework.response import Response from rest_framework.status import HTTP_200_OK +from accounts.serializers import AccountSerializer from accounts.tasks import push_accounts_to_assets_task, verify_accounts_connectivity_task from assets import serializers from assets.exceptions import NotSupportedTemporarilyError @@ -109,18 +110,19 @@ class AssetViewSet(SuggestionMixin, OrgBulkModelViewSet): ("platform", serializers.PlatformSerializer), ("suggestion", serializers.MiniAssetSerializer), ("gateways", serializers.GatewaySerializer), + ("accounts", AccountSerializer), ) rbac_perms = ( ("match", "assets.match_asset"), ("platform", "assets.view_platform"), ("gateways", "assets.view_gateway"), + ("accounts", "assets.view_account"), ("spec_info", "assets.view_asset"), ("gathered_info", "assets.view_asset"), ("sync_platform_protocols", "assets.change_asset"), ) extra_filter_backends = [ - IpInFilterBackend, - NodeFilterBackend, AttrRulesFilterBackend + IpInFilterBackend, NodeFilterBackend, AttrRulesFilterBackend ] def perform_destroy(self, instance): @@ -156,6 +158,12 @@ class AssetViewSet(SuggestionMixin, OrgBulkModelViewSet): gateways = asset.domain.gateways return self.get_paginated_response_from_queryset(gateways) + @action(methods=["GET"], detail=True, url_path="accounts") + def accounts(self, *args, **kwargs): + asset = super().get_object() + queryset = asset.all_accounts.all() + return self.get_paginated_response_from_queryset(queryset) + @action(methods=['post'], detail=False, url_path='sync-platform-protocols') def sync_platform_protocols(self, request, *args, **kwargs): platform_id = request.data.get('platform_id') diff --git a/apps/assets/const/ad.py b/apps/assets/const/ad.py new file mode 100644 index 000000000..ebfa2055f --- /dev/null +++ b/apps/assets/const/ad.py @@ -0,0 +1,73 @@ +from django.utils.translation import gettext_lazy as _ + +from .base import BaseType + + +class ADTypes(BaseType): + AD = 'ad', _('Active Directory') + WINDOWS_AD = 'windows_ad', _('Windows Active Directory') + LDAP = 'ldap', _('LDAP') + AZURE_AD = 'azure_ad', _('Azure Active Directory') + + @classmethod + def _get_base_constrains(cls) -> dict: + return { + '*': { + 'charset_enabled': False, + 'domain_enabled': True, + 'ad_enabled': False, + 'su_enabled': True, + } + } + + @classmethod + def _get_automation_constrains(cls) -> dict: + constrains = { + '*': { + 'ansible_enabled': True, + 'ping_enabled': True, + 'gather_facts_enabled': False, + 'verify_account_enabled': True, + 'change_secret_enabled': True, + 'push_account_enabled': True, + 'gather_accounts_enabled': True, + } + } + return constrains + + @classmethod + def _get_protocol_constrains(cls) -> dict: + return { + cls.WINDOWS_AD: { + 'choices': ['rdp', 'ssh', 'vnc', 'winrm'] + }, + cls.LDAP: { + 'choices': ['ssh', 'ldap'] + }, + cls.AZURE_AD: { + 'choices': ['ldap'] + } + } + + @classmethod + def internal_platforms(cls): + return { + cls.AD: [ + {'name': 'Active Directory'} + ], + cls.WINDOWS_AD: [ + {'name': 'Windows Active Directory'} + ], + cls.LDAP: [ + {'name': 'LDAP'} + ], + cls.AZURE_AD: [ + {'name': 'Azure Active Directory'} + ], + } + + @classmethod + def get_community_types(cls): + return [ + cls.LDAP, + ] diff --git a/apps/assets/const/category.py b/apps/assets/const/category.py index 8c4d387d8..f7a27af32 100644 --- a/apps/assets/const/category.py +++ b/apps/assets/const/category.py @@ -12,6 +12,7 @@ class Category(ChoicesMixin, models.TextChoices): DATABASE = 'database', _("Database") CLOUD = 'cloud', _("Cloud service") WEB = 'web', _("Web") + AD = 'ad', _("Active Directory") CUSTOM = 'custom', _("Custom type") @classmethod diff --git a/apps/assets/const/device.py b/apps/assets/const/device.py index 8860dc3fb..e069a2248 100644 --- a/apps/assets/const/device.py +++ b/apps/assets/const/device.py @@ -20,6 +20,7 @@ class DeviceTypes(BaseType): '*': { 'charset_enabled': False, 'domain_enabled': True, + 'ad_enabled': False, 'su_enabled': True, 'su_methods': ['enable', 'super', 'super_level'] } diff --git a/apps/assets/const/host.py b/apps/assets/const/host.py index 8bd45f257..4435c4482 100644 --- a/apps/assets/const/host.py +++ b/apps/assets/const/host.py @@ -20,6 +20,7 @@ class HostTypes(BaseType): 'charset': 'utf-8', # default 'domain_enabled': True, 'su_enabled': True, + 'ad_enabled': True, 'su_methods': ['sudo', 'su', 'only_sudo', 'only_su'], }, cls.WINDOWS: { @@ -56,7 +57,6 @@ class HostTypes(BaseType): 'change_secret_enabled': True, 'push_account_enabled': True, 'remove_account_enabled': True, - }, cls.WINDOWS: { 'ansible_config': { @@ -69,7 +69,6 @@ class HostTypes(BaseType): 'ping_enabled': False, 'gather_facts_enabled': False, 'gather_accounts_enabled': False, - 'verify_account_enabled': False, 'change_secret_enabled': False, 'push_account_enabled': False }, @@ -126,5 +125,5 @@ class HostTypes(BaseType): @classmethod def get_community_types(cls) -> list: return [ - cls.LINUX, cls.UNIX, cls.WINDOWS, cls.OTHER_HOST + cls.LINUX, cls.WINDOWS, cls.UNIX, cls.OTHER_HOST ] diff --git a/apps/assets/const/types.py b/apps/assets/const/types.py index 1906231c6..ab0f1377b 100644 --- a/apps/assets/const/types.py +++ b/apps/assets/const/types.py @@ -16,13 +16,15 @@ from .device import DeviceTypes from .gpt import GPTTypes from .host import HostTypes from .web import WebTypes +from .ad import ADTypes class AllTypes(ChoicesMixin): choices: list includes = [ HostTypes, DeviceTypes, DatabaseTypes, - CloudTypes, WebTypes, CustomTypes, GPTTypes + CloudTypes, WebTypes, CustomTypes, + ADTypes, GPTTypes ] _category_constrains = {} _automation_methods = None @@ -173,6 +175,7 @@ class AllTypes(ChoicesMixin): (Category.DATABASE, DatabaseTypes), (Category.WEB, WebTypes), (Category.CLOUD, CloudTypes), + (Category.AD, ADTypes), (Category.CUSTOM, CustomTypes) ] return types diff --git a/apps/assets/migrations/0016_auto_20250331_1149.py b/apps/assets/migrations/0016_auto_20250331_1149.py new file mode 100644 index 000000000..ff14fdf9b --- /dev/null +++ b/apps/assets/migrations/0016_auto_20250331_1149.py @@ -0,0 +1,166 @@ +# Generated by Django 4.1.13 on 2025-03-31 02:49 + +import json + +import django +from django.db import migrations, models + +from assets.const.types import AllTypes + + +def add_ad_host_type(apps, schema_editor): + data = """ + [ + { + "created_by": "system", + "updated_by": "system", + "comment": "", + "name": "Windows AD", + "category": "ad", + "type": "windows_ad", + "meta": {}, + "internal": true, + "domain_enabled": true, + "su_enabled": false, + "su_method": null, + "custom_fields": [], + "automation": { + "ansible_enabled": true, + "ansible_config": { + "ansible_shell_type": "cmd", + "ansible_connection": "ssh" + }, + "ping_enabled": true, + "ping_method": "ping_by_rdp", + "ping_params": {}, + "gather_facts_enabled": true, + "gather_facts_method": "gather_facts_windows", + "gather_facts_params": {}, + "change_secret_enabled": true, + "change_secret_method": "change_secret_ad_windows", + "change_secret_params": {}, + "push_account_enabled": true, + "push_account_method": "push_account_ad_windows", + "push_account_params": {}, + "verify_account_enabled": true, + "verify_account_method": "verify_account_by_rdp", + "verify_account_params": {}, + "gather_accounts_enabled": true, + "gather_accounts_method": "gather_accounts_ad_windows", + "gather_accounts_params": {}, + "remove_account_enabled": true, + "remove_account_method": "remove_account_ad_windows", + "remove_account_params": {} + }, + "protocols": [ + { + "name": "rdp", + "port": 3389, + "primary": true, + "required": false, + "default": false, + "public": true, + "setting": { + "console": false, + "security": "any" + } + }, + { + "name": "ssh", + "port": 22, + "primary": false, + "required": false, + "default": false, + "public": true, + "setting": { + "sftp_enabled": true, + "sftp_home": "/tmp" + } + }, + { + "name": "vnc", + "port": 5900, + "primary": false, + "required": false, + "default": false, + "public": true, + "setting": {} + }, + { + "name": "winrm", + "port": 5985, + "primary": false, + "required": false, + "default": false, + "public": false, + "setting": { + "use_ssl": false + } + } + ] + } + ] + """ + platform_model = apps.get_model('assets', 'Platform') + automation_cls = apps.get_model('assets', 'PlatformAutomation') + platform_datas = json.loads(data) + + for platform_data in platform_datas: + AllTypes.create_or_update_by_platform_data(platform_data, platform_cls=platform_model, + automation_cls=automation_cls) + + +class Migration(migrations.Migration): + dependencies = [ + ("assets", "0015_automationexecution_type"), + ] + + operations = [ + migrations.RunPython(add_ad_host_type), + migrations.CreateModel( + name="AD", + fields=[ + ( + "asset_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="assets.asset", + ), + ), + ( + "domain_name", + models.CharField( + blank=True, + default="", + max_length=128, + verbose_name="Domain name", + ), + ), + ], + options={ + "verbose_name": "Active Directory", + }, + bases=("assets.asset",), + ), + migrations.AddField( + model_name="platform", + name="ad", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="ad_platforms", + to="assets.ad", + verbose_name="Active Directory", + ), + ), + migrations.AddField( + model_name="platform", + name="ad_enabled", + field=models.BooleanField(default=False, verbose_name="AD enabled"), + ), + ] diff --git a/apps/assets/models/asset/__init__.py b/apps/assets/models/asset/__init__.py index 7541f2f2e..f93503da6 100644 --- a/apps/assets/models/asset/__init__.py +++ b/apps/assets/models/asset/__init__.py @@ -1,3 +1,4 @@ +from .ad import * from .cloud import * from .common import * from .custom import * diff --git a/apps/assets/models/asset/ad.py b/apps/assets/models/asset/ad.py new file mode 100644 index 000000000..e44017d7d --- /dev/null +++ b/apps/assets/models/asset/ad.py @@ -0,0 +1,13 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from .common import Asset + +__all__ = ['AD'] + + +class AD(Asset): + domain_name = models.CharField(max_length=128, blank=True, default='', verbose_name=_("Domain name")) + + class Meta: + verbose_name = _("Active Directory") diff --git a/apps/assets/models/asset/common.py b/apps/assets/models/asset/common.py index 16f9a8751..ea1367893 100644 --- a/apps/assets/models/asset/common.py +++ b/apps/assets/models/asset/common.py @@ -245,6 +245,20 @@ class Asset(NodesRelationMixin, LabeledMixin, AbsConnectivity, JSONFilterMixin, auto_config.update(model_to_dict(automation)) return auto_config + @property + def all_accounts(self): + if not self.joined_ad_id: + queryset = self.accounts.all() + else: + queryset = self.accounts.model.objects.filter(asset__in=[self.id, self.joined_ad_id]) + return queryset + + @lazyproperty + def all_valid_accounts(self): + queryset = (self.all_accounts.filter(is_active=True) + .prefetch_related('asset', 'asset__platform', 'asset__platform__ad')) + return queryset + @lazyproperty def accounts_amount(self): return self.accounts.count() @@ -259,6 +273,19 @@ class Asset(NodesRelationMixin, LabeledMixin, AbsConnectivity, JSONFilterMixin, protocol = self.protocols.all().filter(name=protocol).first() return protocol.port if protocol else 0 + def is_ad(self): + return self.category == const.Category.AD + + @property + def joined_ad_id(self): + return self.platform.ad_id + + def is_joined_ad(self): + if self.joined_ad_id: + return True + else: + return False + @property def is_valid(self): warning = '' diff --git a/apps/assets/models/platform.py b/apps/assets/models/platform.py index 354914834..1e45b22ab 100644 --- a/apps/assets/models/platform.py +++ b/apps/assets/models/platform.py @@ -102,6 +102,11 @@ class Platform(LabeledMixin, JMSBaseModel): max_length=8, verbose_name=_("Charset") ) domain_enabled = models.BooleanField(default=True, verbose_name=_("Gateway enabled")) + ad_enabled = models.BooleanField(default=False, verbose_name=_("AD enabled")) + ad = models.ForeignKey( + 'assets.AD', on_delete=models.SET_NULL, null=True, blank=True, + verbose_name=_("Active Directory"), related_name='ad_platforms' + ) # 账号有关的 su_enabled = models.BooleanField(default=False, verbose_name=_("Su enabled")) su_method = models.CharField(max_length=32, blank=True, null=True, verbose_name=_("Su method")) @@ -115,6 +120,11 @@ class Platform(LabeledMixin, JMSBaseModel): def assets_amount(self): return self.assets.count() + def save(self, *args, **kwargs): + if not self.ad_enabled: + self.ad = None + super().save(*args, **kwargs) + @classmethod def default(cls): linux, created = cls.objects.get_or_create( diff --git a/apps/assets/serializers/asset/__init__.py b/apps/assets/serializers/asset/__init__.py index 481e90863..e0f1e9b7b 100644 --- a/apps/assets/serializers/asset/__init__.py +++ b/apps/assets/serializers/asset/__init__.py @@ -7,3 +7,4 @@ from .device import * from .gpt import * from .host import * from .web import * +from .ad import * \ No newline at end of file diff --git a/apps/assets/serializers/asset/ad.py b/apps/assets/serializers/asset/ad.py new file mode 100644 index 000000000..2c8a3eac2 --- /dev/null +++ b/apps/assets/serializers/asset/ad.py @@ -0,0 +1,23 @@ +from django.utils.translation import gettext_lazy as _ +from rest_framework import serializers + +from assets.models import AD +from .common import AssetSerializer + +__all__ = ['ADSerializer'] + + +class ADSerializer(AssetSerializer): + class Meta(AssetSerializer.Meta): + model = AD + fields = AssetSerializer.Meta.fields + [ + 'domain_name', + ] + extra_kwargs = { + **AssetSerializer.Meta.extra_kwargs, + 'domain_name': { + 'help_text': _( + 'The domain name of the Active Directory' + ), + 'label': _('Domain name')} + } diff --git a/apps/assets/serializers/asset/common.py b/apps/assets/serializers/asset/common.py index 5f407267e..57642fda5 100644 --- a/apps/assets/serializers/asset/common.py +++ b/apps/assets/serializers/asset/common.py @@ -147,7 +147,8 @@ class AssetSerializer(BulkOrgResourceModelSerializer, ResourceLabelsMixin, Writa protocols = AssetProtocolsSerializer(many=True, required=False, label=_('Protocols'), default=()) accounts = AssetAccountSerializer(many=True, required=False, allow_null=True, write_only=True, label=_('Accounts')) nodes_display = NodeDisplaySerializer(read_only=False, required=False, label=_("Node path")) - platform = ObjectRelatedField(queryset=Platform.objects, required=True, label=_('Platform'), attrs=('id', 'name', 'type')) + platform = ObjectRelatedField(queryset=Platform.objects, required=True, label=_('Platform'), + attrs=('id', 'name', 'type', 'ad_id')) accounts_amount = serializers.IntegerField(read_only=True, label=_('Accounts amount')) _accounts = None diff --git a/apps/assets/serializers/domain.py b/apps/assets/serializers/domain.py index ccd970edf..9181db18c 100644 --- a/apps/assets/serializers/domain.py +++ b/apps/assets/serializers/domain.py @@ -4,12 +4,11 @@ from django.db.models import Count, Q from django.utils.translation import gettext_lazy as _ from rest_framework import serializers -from assets.models.gateway import Gateway from common.serializers import ResourceLabelsMixin from common.serializers.fields import ObjectRelatedField from orgs.mixins.serializers import BulkOrgResourceModelSerializer from .gateway import GatewayWithAccountSecretSerializer -from ..models import Domain +from ..models import Domain, Gateway __all__ = ['DomainSerializer', 'DomainWithGatewaySerializer', 'DomainListSerializer'] diff --git a/apps/assets/serializers/platform.py b/apps/assets/serializers/platform.py index 2d25f2fe3..86921e330 100644 --- a/apps/assets/serializers/platform.py +++ b/apps/assets/serializers/platform.py @@ -194,7 +194,7 @@ class PlatformSerializer(ResourceLabelsMixin, CommonSerializerMixin, WritableNes ] fields_m2m = ['assets', 'assets_amount'] fields = fields_small + fields_m2m + [ - "protocols", "domain_enabled", "su_enabled", "su_method", + "protocols", "domain_enabled", "su_enabled", "su_method", "ad_enabled", "ad", "automation", "comment", "custom_fields", "labels" ] + read_only_fields extra_kwargs = { diff --git a/apps/assets/urls/api_urls.py b/apps/assets/urls/api_urls.py index d19d761b3..7d23738cf 100644 --- a/apps/assets/urls/api_urls.py +++ b/apps/assets/urls/api_urls.py @@ -16,6 +16,7 @@ router.register(r'databases', api.DatabaseViewSet, 'database') router.register(r'webs', api.WebViewSet, 'web') router.register(r'clouds', api.CloudViewSet, 'cloud') router.register(r'gpts', api.GPTViewSet, 'gpt') +router.register(r'directories', api.ADViewSet, 'ad') router.register(r'customs', api.CustomViewSet, 'custom') router.register(r'platforms', api.AssetPlatformViewSet, 'platform') router.register(r'nodes', api.NodeViewSet, 'node') diff --git a/apps/common/api/mixin.py b/apps/common/api/mixin.py index ea51dd68e..4d06cdee1 100644 --- a/apps/common/api/mixin.py +++ b/apps/common/api/mixin.py @@ -95,7 +95,9 @@ class QuerySetMixin: get_queryset: Callable def get_queryset(self): - queryset = super().get_queryset() + return super().get_queryset() + + def filter_queryset(self, queryset): if not hasattr(self, 'action'): return queryset if self.action == 'metadata': @@ -105,8 +107,9 @@ class QuerySetMixin: def setup_eager_loading(self, queryset, is_paginated=False): is_export_request = self.request.query_params.get('format') in ['csv', 'xlsx'] + no_request_page = self.request.query_params.get('limit') is None # 不分页不走一般这个,是因为会消耗多余的 sql 查询, 不如分页的时候查询一次 - if not is_export_request and not is_paginated: + if not is_export_request and not is_paginated and not no_request_page: return queryset serializer_class = self.get_serializer_class() @@ -129,7 +132,7 @@ class QuerySetMixin: serializer_class = self.get_serializer_class() if page and serializer_class: ids = [str(obj.id) for obj in page] - page = self.get_queryset().filter(id__in=ids) + page = model.objects.filter(id__in=ids) page = self.setup_eager_loading(page, is_paginated=True) page_mapper = {str(obj.id): obj for obj in page} page = [page_mapper.get(_id) for _id in ids if _id in page_mapper] diff --git a/apps/perms/serializers/user_permission.py b/apps/perms/serializers/user_permission.py index d00e16d18..c1a2ecaa4 100644 --- a/apps/perms/serializers/user_permission.py +++ b/apps/perms/serializers/user_permission.py @@ -59,6 +59,7 @@ class NodePermedSerializer(serializers.ModelSerializer): class AccountsPermedSerializer(serializers.ModelSerializer): actions = ActionChoicesField(read_only=True) + username = serializers.CharField(source='full_username', read_only=True) class Meta: model = Account diff --git a/apps/perms/utils/asset_perm.py b/apps/perms/utils/asset_perm.py index f6d4ab2ca..992f8f6c5 100644 --- a/apps/perms/utils/asset_perm.py +++ b/apps/perms/utils/asset_perm.py @@ -88,7 +88,7 @@ class PermAssetDetailUtil: if not all_action_bit: return alias_action_bit_mapper, alias_date_expired_mapper - asset_account_usernames = asset.accounts.all().active().values_list('username', flat=True) + asset_account_usernames = asset.all_valid_accounts.values_list('username', flat=True) for username in asset_account_usernames: alias_action_bit_mapper[username] |= all_action_bit alias_date_expired_mapper[username].extend( @@ -100,7 +100,7 @@ class PermAssetDetailUtil: def map_alias_to_accounts(cls, alias_action_bit_mapper, alias_date_expired_mapper, asset, user): username_accounts_mapper = defaultdict(list) cleaned_accounts_expired = defaultdict(list) - asset_accounts = asset.accounts.all().active() + asset_accounts = asset.all_valid_accounts # 用户名 -> 账号 for account in asset_accounts: @@ -135,11 +135,18 @@ class PermAssetDetailUtil: alias_action_bit_mapper, alias_date_expired_mapper, asset, user ) accounts = [] + virtual_accounts = [] for account, action_bit in cleaned_accounts_action_bit.items(): account.actions = action_bit account.date_expired = max(cleaned_accounts_expired[account]) - accounts.append(account) - return accounts + + if account.username.startswith('@'): + virtual_accounts.append(account) + else: + accounts.append(account) + accounts.sort(key=lambda x: x.username) + virtual_accounts.sort(key=lambda x: x.username) + return accounts + virtual_accounts def check_perm_protocols(self, protocols): """