perf: ad as asset

pull/15176/head
ibuler 2025-04-02 19:12:09 +08:00 committed by 老广
parent 5e25361ee8
commit 3f452daee8
24 changed files with 391 additions and 19 deletions

View File

@ -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):

View File

@ -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

View File

@ -7,3 +7,4 @@ from .gpt import *
from .host import *
from .permission import *
from .web import *
from .ad import *

View File

@ -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

View File

@ -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')

73
apps/assets/const/ad.py Normal file
View File

@ -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,
]

View File

@ -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

View File

@ -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']
}

View File

@ -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
]

View File

@ -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

View File

@ -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"),
),
]

View File

@ -1,3 +1,4 @@
from .ad import *
from .cloud import *
from .common import *
from .custom import *

View File

@ -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")

View File

@ -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 = ''

View File

@ -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(

View File

@ -7,3 +7,4 @@ from .device import *
from .gpt import *
from .host import *
from .web import *
from .ad import *

View File

@ -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')}
}

View File

@ -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

View File

@ -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']

View File

@ -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 = {

View File

@ -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')

View File

@ -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]

View File

@ -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

View File

@ -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):
"""