perf: Change secret after successful login

pull/15547/head
feng 2025-05-21 10:09:58 +08:00 committed by 老广
parent 3991976a00
commit b70fb58faf
17 changed files with 344 additions and 27 deletions

View File

@ -6,10 +6,13 @@ from rest_framework.decorators import action
from rest_framework.response import Response
from accounts import serializers
from accounts.const import AutomationTypes, ChangeSecretRecordStatusChoice
from accounts.filters import ChangeSecretRecordFilterSet
from accounts.models import ChangeSecretAutomation, ChangeSecretRecord
from accounts.const import (
AutomationTypes, ChangeSecretRecordStatusChoice
)
from accounts.filters import ChangeSecretRecordFilterSet, ChangeSecretStatusFilterSet
from accounts.models import ChangeSecretAutomation, ChangeSecretRecord, Account
from accounts.tasks import execute_automation_record_task
from accounts.utils import account_secret_task_status
from authentication.permissions import UserConfirmation, ConfirmType
from common.permissions import IsValidLicense
from orgs.mixins.api import OrgBulkModelViewSet, OrgGenericViewSet
@ -23,7 +26,7 @@ __all__ = [
'ChangeSecretAutomationViewSet', 'ChangeSecretRecordViewSet',
'ChangSecretExecutionViewSet', 'ChangSecretAssetsListApi',
'ChangSecretRemoveAssetApi', 'ChangSecretAddAssetApi',
'ChangSecretNodeAddRemoveApi'
'ChangSecretNodeAddRemoveApi', 'ChangeSecretStatusViewSet'
]
@ -154,3 +157,24 @@ class ChangSecretAddAssetApi(AutomationAddAssetApi):
class ChangSecretNodeAddRemoveApi(AutomationNodeAddRemoveApi):
model = ChangeSecretAutomation
serializer_class = serializers.ChangeSecretUpdateNodeSerializer
class ChangeSecretStatusViewSet(OrgBulkModelViewSet):
perm_model = ChangeSecretAutomation
filterset_class = ChangeSecretStatusFilterSet
serializer_class = serializers.ChangeSecretAccountSerializer
permission_classes = [RBACPermission, IsValidLicense]
http_method_names = ["get", "delete", "options"]
def get_queryset(self):
account_ids = list(account_secret_task_status.account_ids)
return Account.objects.filter(id__in=account_ids).select_related('asset')
def bulk_destroy(self, request, *args, **kwargs):
account_ids = request.data.get('account_ids')
if isinstance(account_ids, str):
account_ids = [account_ids]
for _id in account_ids:
account_secret_task_status.clear(_id)
return Response(status=status.HTTP_200_OK)

View File

@ -5,9 +5,10 @@ from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from accounts.automations.methods import platform_automation_methods
from accounts.const import SSHKeyStrategy, SecretStrategy, SecretType, ChangeSecretRecordStatusChoice
from accounts.const import SSHKeyStrategy, SecretStrategy, SecretType, ChangeSecretRecordStatusChoice, \
ChangeSecretAccountStatus
from accounts.models import BaseAccountQuerySet
from accounts.utils import SecretGenerator
from accounts.utils import SecretGenerator, account_secret_task_status
from assets.automations.base.manager import BasePlaybookManager
from assets.const import HostTypes
from common.db.utils import safe_atomic_db_connection
@ -132,10 +133,24 @@ class BaseChangeSecretPushManager(AccountBasePlaybookManager):
for account in accounts:
h = deepcopy(host)
h['name'] += '(' + account.username + ')' # To distinguish different accounts
account_status = account_secret_task_status.get_status(account.id)
if account_status == ChangeSecretAccountStatus.PROCESSING:
h['error'] = f'Account is already being processed, skipping: {account}'
inventory_hosts.append(h)
continue
try:
h = self.gen_account_inventory(account, asset, h, path_dir)
account_secret_task_status.set_status(
account.id,
ChangeSecretAccountStatus.PROCESSING,
metadata={'execution_id': self.execution.id}
)
except Exception as e:
h['error'] = str(e)
self.clear_account_queue_status(account)
inventory_hosts.append(h)
return inventory_hosts
@ -144,6 +159,10 @@ class BaseChangeSecretPushManager(AccountBasePlaybookManager):
def save_record(recorder):
recorder.save(update_fields=['error', 'status', 'date_finished'])
@staticmethod
def clear_account_queue_status(account):
account_secret_task_status.clear(account.id)
def on_host_success(self, host, result):
recorder = self.name_recorder_mapper.get(host)
if not recorder:
@ -173,6 +192,7 @@ class BaseChangeSecretPushManager(AccountBasePlaybookManager):
with safe_atomic_db_connection():
account.save(update_fields=['secret', 'date_updated', 'date_change_secret', 'change_secret_status'])
self.save_record(recorder)
self.clear_account_queue_status(account)
def on_host_error(self, host, error, result):
recorder = self.name_recorder_mapper.get(host)
@ -201,3 +221,4 @@ class BaseChangeSecretPushManager(AccountBasePlaybookManager):
with safe_atomic_db_connection():
account.save(update_fields=['change_secret_status', 'date_change_secret', 'date_updated'])
self.save_record(recorder)
self.clear_account_queue_status(account)

View File

@ -17,7 +17,7 @@ __all__ = [
'AutomationTypes', 'SecretStrategy', 'SSHKeyStrategy', 'Connectivity',
'DEFAULT_PASSWORD_LENGTH', 'DEFAULT_PASSWORD_RULES', 'TriggerChoice',
'PushAccountActionChoice', 'AccountBackupType', 'ChangeSecretRecordStatusChoice',
'GatherAccountDetailField'
'GatherAccountDetailField', 'ChangeSecretAccountStatus'
]
@ -117,6 +117,12 @@ class ChangeSecretRecordStatusChoice(models.TextChoices):
pending = 'pending', _('Pending')
class ChangeSecretAccountStatus(models.TextChoices):
QUEUED = 'queued', _('Queued')
READY = 'ready', _('Ready')
PROCESSING = 'processing', _('Processing')
class GatherAccountDetailField(models.TextChoices):
can_login = 'can_login', _('Can login')
superuser = 'superuser', _('Superuser')

View File

@ -17,6 +17,7 @@ from common.utils.timezone import local_zero_hour, local_now
from .const.automation import ChangeSecretRecordStatusChoice
from .models import Account, GatheredAccount, ChangeSecretRecord, PushSecretRecord, IntegrationApplication, \
AutomationExecution
from .utils import account_secret_task_status
logger = get_logger(__file__)
@ -242,3 +243,26 @@ class PushAccountRecordFilterSet(SecretRecordMixin, UUIDFilterMixin, BaseFilterS
class Meta:
model = PushSecretRecord
fields = ["id", "status", "asset_id", "execution_id"]
class ChangeSecretStatusFilterSet(BaseFilterSet):
asset_id = drf_filters.CharFilter(field_name="asset_id", lookup_expr="exact")
asset_name = drf_filters.CharFilter(
field_name="asset__name", lookup_expr="icontains"
)
status = drf_filters.CharFilter(method='filter_dynamic')
execution_id = drf_filters.CharFilter(method='filter_dynamic')
class Meta:
model = Account
fields = ["id", "username"]
@staticmethod
def filter_dynamic(queryset, name, value):
_ids = list(queryset.values_list('id', flat=True))
data_map = {
_id: account_secret_task_status.get(str(_id)).get(name)
for _id in _ids
}
matched = [_id for _id, v in data_map.items() if v == value]
return queryset.filter(id__in=matched)

View File

@ -16,6 +16,7 @@ from assets.models import Asset
from common.serializers.fields import LabeledChoiceField, ObjectRelatedField
from common.utils import get_logger
from .base import BaseAutomationSerializer
from ...utils import account_secret_task_status
logger = get_logger(__file__)
@ -26,6 +27,7 @@ __all__ = [
'ChangeSecretRecordBackUpSerializer',
'ChangeSecretUpdateAssetSerializer',
'ChangeSecretUpdateNodeSerializer',
'ChangeSecretAccountSerializer'
]
@ -179,3 +181,24 @@ class ChangeSecretUpdateNodeSerializer(serializers.ModelSerializer):
class Meta:
model = ChangeSecretAutomation
fields = ['id', 'nodes']
class ChangeSecretAccountSerializer(serializers.ModelSerializer):
asset = ObjectRelatedField(
queryset=Asset.objects.all(), required=False, label=_("Asset")
)
ttl = serializers.SerializerMethodField(label=_('TTL'))
meta = serializers.SerializerMethodField(label=_('Meta'))
class Meta:
model = Account
fields = ['id', 'username', 'asset', 'meta', 'ttl']
read_only_fields = fields
@staticmethod
def get_meta(obj):
return account_secret_task_status.get(str(obj.id))
@staticmethod
def get_ttl(obj):
return account_secret_task_status.get_ttl(str(obj.id))

View File

@ -1,37 +1,108 @@
from collections import defaultdict
from celery import shared_task
from django.utils.translation import gettext_noop, gettext_lazy as _
from accounts.const import AutomationTypes
from accounts.const import AutomationTypes, ChangeSecretAccountStatus
from accounts.tasks.common import quickstart_automation_by_snapshot
from accounts.utils import account_secret_task_status
from common.utils import get_logger
from orgs.utils import tmp_to_org
logger = get_logger(__file__)
__all__ = [
'push_accounts_to_assets_task',
'push_accounts_to_assets_task', 'change_secret_accounts_to_assets_task'
]
def _process_accounts(account_ids, automation_model, default_name, automation_type, snapshot=None):
from accounts.models import Account
accounts = Account.objects.filter(id__in=account_ids)
if not accounts:
logger.warning(
"No accounts found for automation task %s with ids %s",
automation_type, account_ids
)
return
task_name = automation_model.generate_unique_name(gettext_noop(default_name))
snapshot = snapshot or {}
snapshot.update({
'accounts': [str(a.id) for a in accounts],
'assets': [str(a.asset_id) for a in accounts],
})
quickstart_automation_by_snapshot(task_name, automation_type, snapshot)
@shared_task(
queue="ansible",
verbose_name=_('Push accounts to assets'),
activity_callback=lambda self, account_ids, *args, **kwargs: (account_ids, None),
description=_(
"When creating or modifying an account requires account push, this task is executed"
"Whenever an account is created or modified and needs pushing to assets, run this task"
)
)
def push_accounts_to_assets_task(account_ids, params=None):
from accounts.models import PushAccountAutomation
from accounts.models import Account
accounts = Account.objects.filter(id__in=account_ids)
task_name = gettext_noop("Push accounts to assets")
task_name = PushAccountAutomation.generate_unique_name(task_name)
task_snapshot = {
'accounts': [str(account.id) for account in accounts],
'assets': [str(account.asset_id) for account in accounts],
snapshot = {
'params': params or {},
}
_process_accounts(
account_ids,
PushAccountAutomation,
_("Push accounts to assets"),
AutomationTypes.push_account,
snapshot=snapshot
)
tp = AutomationTypes.push_account
quickstart_automation_by_snapshot(task_name, tp, task_snapshot)
@shared_task(
queue="ansible",
verbose_name=_('Change secret accounts to assets'),
activity_callback=lambda self, account_ids, *args, **kwargs: (account_ids, None),
description=_(
"When a secret on an account changes and needs pushing to assets, run this task"
)
)
def change_secret_accounts_to_assets_task(account_ids, params=None, snapshot=None, trigger='manual'):
from accounts.models import ChangeSecretAutomation, Account
manager = account_secret_task_status
if trigger == 'delay':
for _id in manager.account_ids:
status = manager.get_status(_id)
ttl = manager.get_ttl(_id)
# Check if the account is in QUEUED status
if status == ChangeSecretAccountStatus.QUEUED and ttl <= 15:
account_ids.append(_id)
manager.set_status(_id, ChangeSecretAccountStatus.READY)
if not account_ids:
return
accounts = Account.objects.filter(id__in=account_ids)
if not accounts:
logger.warning(
"No accounts found for change secret automation task with ids %s",
account_ids
)
return
grouped_ids = defaultdict(lambda: defaultdict(list))
for account in accounts:
grouped_ids[account.org_id][account.secret_type].append(str(account.id))
snapshot = snapshot or {}
for org_id, secret_map in grouped_ids.items():
with tmp_to_org(org_id):
for secret_type, ids in secret_map.items():
snapshot['secret_type'] = secret_type
_process_accounts(
ids,
ChangeSecretAutomation,
_("Change secret accounts to assets"),
AutomationTypes.change_secret,
snapshot=snapshot
)

View File

@ -17,6 +17,7 @@ router.register(r'account-template-secrets', api.AccountTemplateSecretsViewSet,
router.register(r'account-backup-plans', api.BackupAccountViewSet, 'account-backup')
router.register(r'account-backup-plan-executions', api.BackupAccountExecutionViewSet, 'account-backup-execution')
router.register(r'change-secret-automations', api.ChangeSecretAutomationViewSet, 'change-secret-automation')
router.register(r'change-secret-status', api.ChangeSecretStatusViewSet, 'change-secret-status')
router.register(r'change-secret-executions', api.ChangSecretExecutionViewSet, 'change-secret-execution')
router.register(r'change-secret-records', api.ChangeSecretRecordViewSet, 'change-secret-record')
router.register(r'gather-account-automations', api.DiscoverAccountsAutomationViewSet, 'gather-account-automation')

View File

@ -1,10 +1,11 @@
import copy
from django.conf import settings
from django.core.cache import cache
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from accounts.const import SecretType, DEFAULT_PASSWORD_RULES
from common.utils import ssh_key_gen, random_string
from common.utils import validate_ssh_private_key, parse_ssh_private_key_str
@ -61,3 +62,80 @@ def validate_ssh_key(ssh_key, passphrase=None):
if not valid:
raise serializers.ValidationError(_("private key invalid or passphrase error"))
return parse_ssh_private_key_str(ssh_key, passphrase)
class AccountSecretTaskStatus:
def __init__(
self,
prefix='queue:change_secret:',
debounce_key='debounce:change_secret:task',
debounce_timeout=10,
queue_status_timeout=35,
default_timeout=3600,
delayed_task_countdown=20,
):
self.prefix = prefix
self.debounce_key = debounce_key
self.debounce_timeout = debounce_timeout
self.queue_status_timeout = queue_status_timeout
self.default_timeout = default_timeout
self.delayed_task_countdown = delayed_task_countdown
self.enabled = getattr(settings, 'CHANGE_SECRET_AFTER_SESSION_END', False)
def _key(self, identifier):
return f"{self.prefix}{identifier}"
@property
def account_ids(self):
for key in cache.iter_keys(f"{self.prefix}*"):
yield key.split(':')[-1]
def is_debounced(self):
return cache.add(self.debounce_key, True, self.debounce_timeout)
def get_queue_key(self, identifier):
return self._key(identifier)
def set_status(
self,
identifier,
status,
timeout=None,
metadata=None,
use_add=False
):
if not self.enabled:
return
key = self._key(identifier)
data = {"status": status}
if metadata:
data.update(metadata)
if use_add:
return cache.set(key, data, timeout or self.queue_status_timeout)
cache.set(key, data, timeout or self.default_timeout)
def get(self, identifier):
return cache.get(self._key(identifier), {})
def get_status(self, identifier):
if not self.enabled:
return
record = cache.get(self._key(identifier), {})
return record.get("status")
def get_ttl(self, identifier):
return cache.ttl(self._key(identifier))
def clear(self, identifier):
if not self.enabled:
return
cache.delete(self._key(identifier))
account_secret_task_status = AccountSecretTaskStatus()

View File

@ -11,3 +11,4 @@ class ActionChoices(models.TextChoices):
notify_and_warn = 'notify_and_warn', _('Prompt and warn')
face_verify = 'face_verify', _('Face Verify')
face_online = 'face_online', _('Face Online')
change_secret = 'change_secret', _('Change password')

View File

@ -79,6 +79,8 @@ class ActionAclSerializer(serializers.Serializer):
field_action._choices.pop(ActionChoices.face_online, None)
for choice in self.Meta.action_choices_exclude:
field_action._choices.pop(choice, None)
if not settings.CHANGE_SECRET_AFTER_SESSION_END:
field_action._choices.pop(ActionChoices.change_secret, None)
class BaseACLSerializer(ActionAclSerializer, serializers.Serializer):

View File

@ -33,7 +33,10 @@ class CommandFilterACLSerializer(BaseSerializer, BulkOrgResourceModelSerializer)
model = CommandFilterACL
fields = BaseSerializer.Meta.fields + ['command_groups']
action_choices_exclude = [
ActionChoices.notice, ActionChoices.face_verify, ActionChoices.face_online
ActionChoices.notice,
ActionChoices.face_verify,
ActionChoices.face_online,
ActionChoices.change_secret
]

View File

@ -14,6 +14,10 @@ class ConnectMethodACLSerializer(BaseSerializer, BulkOrgResourceModelSerializer)
if i not in ['assets', 'accounts']
]
action_choices_exclude = BaseSerializer.Meta.action_choices_exclude + [
ActionChoices.review, ActionChoices.accept, ActionChoices.notice,
ActionChoices.face_verify, ActionChoices.face_online
ActionChoices.review,
ActionChoices.accept,
ActionChoices.notice,
ActionChoices.face_verify,
ActionChoices.face_online,
ActionChoices.change_secret
]

View File

@ -22,7 +22,8 @@ class LoginACLSerializer(BaseUserACLSerializer, BulkOrgResourceModelSerializer):
ActionChoices.warning,
ActionChoices.notify_and_warn,
ActionChoices.face_online,
ActionChoices.face_verify
ActionChoices.face_verify,
ActionChoices.change_secret
]
def get_rules_serializer(self):

View File

@ -607,6 +607,7 @@ class Config(dict):
'SECURITY_CHECK_DIFFERENT_CITY_LOGIN': True,
'OLD_PASSWORD_HISTORY_LIMIT_COUNT': 5,
'CHANGE_AUTH_PLAN_SECURE_MODE_ENABLED': True,
'CHANGE_SECRET_AFTER_SESSION_END': False,
'USER_LOGIN_SINGLE_MACHINE_ENABLED': False,
'ONLY_ALLOW_EXIST_USER_AUTH': False,
'ONLY_ALLOW_AUTH_FROM_SOURCE': False,

View File

@ -141,6 +141,7 @@ WINDOWS_SKIP_ALL_MANUAL_PASSWORD = CONFIG.WINDOWS_SKIP_ALL_MANUAL_PASSWORD
AUTH_EXPIRED_SECONDS = 60 * 10
CHANGE_AUTH_PLAN_SECURE_MODE_ENABLED = CONFIG.CHANGE_AUTH_PLAN_SECURE_MODE_ENABLED
CHANGE_SECRET_AFTER_SESSION_END = CONFIG.CHANGE_SECRET_AFTER_SESSION_END
DATETIME_DISPLAY_FORMAT = '%Y-%m-%d %H:%M:%S'

View File

@ -82,6 +82,7 @@ class PrivateSettingSerializer(PublicSettingSerializer):
USER_DEFAULT_EXPIRED_DAYS = serializers.IntegerField()
ASSET_PERMISSION_DEFAULT_EXPIRED_DAYS = serializers.IntegerField()
PRIVACY_MODE = serializers.BooleanField()
CHANGE_SECRET_AFTER_SESSION_END = serializers.BooleanField()
class ServerInfoSerializer(serializers.Serializer):

View File

@ -1,9 +1,15 @@
from django.conf import settings
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from accounts.const import ChangeSecretAccountStatus, SecretStrategy
from accounts.models import Account
from accounts.tasks import change_secret_accounts_to_assets_task
from accounts.utils import account_secret_task_status
from acls.models import LoginAssetACL
from assets.models import Asset
from common.serializers.fields import LabeledChoiceField
from common.utils import pretty_string
from common.utils import pretty_string, get_logger
from orgs.mixins.serializers import BulkOrgResourceModelSerializer
from terminal.session_lifecycle import lifecycle_events_map
from users.models import User
@ -11,6 +17,8 @@ from .terminal import TerminalSmallSerializer
from ..const import SessionType, SessionErrorReason
from ..models import Session
logger = get_logger(__file__)
__all__ = [
'SessionSerializer', 'SessionDisplaySerializer',
'ReplaySerializer', 'SessionJoinValidateSerializer',
@ -84,6 +92,47 @@ class SessionSerializer(BulkOrgResourceModelSerializer):
raise serializers.ValidationError({field_name: error_message})
return instance
@staticmethod
def enqueue_change_secret_task(instance):
asset = Asset.objects.filter(id=instance.asset_id).first()
user = User.objects.filter(id=instance.user_id).first()
if not asset or not user:
logger.warning(
f"Invalid asset or user for change secret task: asset={instance.asset}, user={instance.user}"
)
return
kwargs = {'user': user, 'asset': asset}
account_id = instance.account_id
try:
account = Account.objects.get(id=account_id)
kwargs['account'] = account
except Account.DoesNotExist:
logger.warning(f"Account with id {account_id} does not exist for change secret task.")
return
acls = LoginAssetACL.filter_queryset(**kwargs)
acl = LoginAssetACL.get_match_rule_acls(user, instance.remote_addr, acls)
if not acl:
return
if not acl.is_action(acl.ActionChoices.change_secret):
return
manager = account_secret_task_status
manager.set_status(account.id, ChangeSecretAccountStatus.QUEUED, use_add=True)
if manager.is_debounced():
snapshot = {
'check_conn_after_change': False,
'secret_strategy': SecretStrategy.random,
}
change_secret_accounts_to_assets_task.apply_async(
args=[[]], # Pass an empty list as account_ids
kwargs={
'snapshot': snapshot,
'trigger': 'delay',
},
countdown=manager.delayed_task_countdown,
)
def create(self, validated_data):
user_id = validated_data.get('user_id')
asset_id = validated_data.get('asset_id')
@ -107,6 +156,12 @@ class SessionSerializer(BulkOrgResourceModelSerializer):
validated_data['asset'] = str(asset)
return super().create(validated_data)
def update(self, instance, validated_data):
is_finished = validated_data.get('is_finished')
if settings.CHANGE_SECRET_AFTER_SESSION_END and is_finished and not instance.is_finished:
self.enqueue_change_secret_task(instance)
return super().update(instance, validated_data)
class SessionDisplaySerializer(SessionSerializer):
command_amount = serializers.IntegerField(read_only=True, label=_('Command amount'))