Compare commits

..

3 Commits

Author SHA1 Message Date
ibuler
e41f6e27e2 perf: update schema 2025-12-12 15:40:40 +08:00
ibuler
d2386fb56c perf: update swagger for mcp 2025-12-10 18:17:10 +08:00
fit2bot
5f1ba56e56 Merge pull request #16094 from jumpserver/pr@dev@chat_model
perf: Add open ui chat model
2025-12-10 10:43:14 +08:00
87 changed files with 7380 additions and 5305 deletions

View File

@@ -1,4 +1,3 @@
from django.conf import settings
from django.db import transaction
from django.shortcuts import get_object_or_404
from django.utils.translation import gettext_lazy as _
@@ -167,7 +166,7 @@ class AccountViewSet(OrgBulkModelViewSet):
class AccountSecretsViewSet(AccountRecordViewLogMixin, AccountViewSet):
"""
因为可能要导出所有账号,所以单独建立了一个 viewset
因为可能要导出所有账号所以单独建立了一个 viewset
"""
serializer_classes = {
'default': serializers.AccountSecretSerializer,

View File

@@ -81,7 +81,4 @@ class IntegrationApplicationViewSet(OrgBulkModelViewSet):
remote_addr=get_request_ip(request), service=service.name, service_id=service.id,
account=f'{account.name}({account.username})', asset=f'{asset.name}({asset.address})',
)
# 根据配置决定是否返回密码
secret = account.secret if settings.SECURITY_ACCOUNT_SECRET_READ else None
return Response(data={'id': request.user.id, 'secret': secret})
return Response(data={'id': request.user.id, 'secret': account.secret})

View File

@@ -1,5 +1,3 @@
from django.conf import settings
from django.utils.translation import gettext_lazy as _
from django_filters import rest_framework as drf_filters
from rest_framework import status
from rest_framework.decorators import action

View File

@@ -104,7 +104,7 @@ class AutomationExecutionViewSet(
mixins.CreateModelMixin, mixins.ListModelMixin,
mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
search_fields = ('id', 'trigger', 'automation__name')
search_fields = ('trigger', 'automation__name')
filterset_fields = ('trigger', 'automation_id', 'automation__name')
filterset_class = AutomationExecutionFilterSet
serializer_class = serializers.AutomationExecutionSerializer

View File

@@ -234,7 +234,7 @@ class AutomationExecutionFilterSet(DaysExecutionFilterMixin, BaseFilterSet):
class Meta:
model = AutomationExecution
fields = ["id", "days", 'trigger', 'automation__name']
fields = ["days", 'trigger', 'automation__name']
class PushAccountRecordFilterSet(SecretRecordMixin, UUIDFilterMixin, BaseFilterSet):

View File

@@ -81,9 +81,7 @@ class VaultModelMixin(models.Model):
def mark_secret_save_to_vault(self):
self._secret = self._secret_save_to_vault_mark
self.skip_history_when_saving = True
# Avoid calling overridden `save()` on concrete models (e.g. AccountTemplate)
# which may mutate `secret/_secret` again and cause post_save recursion.
super(VaultModelMixin, self).save(update_fields=['_secret'])
self.save()
@property
def secret_has_save_to_vault(self):

View File

@@ -14,7 +14,7 @@ from accounts.models import Account, AccountTemplate, GatheredAccount
from accounts.tasks import push_accounts_to_assets_task
from assets.const import Category, AllTypes
from assets.models import Asset
from common.serializers import SecretReadableMixin, SecretReadableCheckMixin, CommonBulkModelSerializer
from common.serializers import SecretReadableMixin, CommonBulkModelSerializer
from common.serializers.fields import ObjectRelatedField, LabeledChoiceField
from common.utils import get_logger
from .base import BaseAccountSerializer, AuthValidateMixin
@@ -478,7 +478,7 @@ class AssetAccountBulkSerializer(
return results
class AccountSecretSerializer(SecretReadableCheckMixin, SecretReadableMixin, AccountSerializer):
class AccountSecretSerializer(SecretReadableMixin, AccountSerializer):
spec_info = serializers.DictField(label=_('Spec info'), read_only=True)
class Meta(AccountSerializer.Meta):
@@ -491,10 +491,9 @@ class AccountSecretSerializer(SecretReadableCheckMixin, SecretReadableMixin, Acc
exclude_backup_fields = [
'passphrase', 'push_now', 'params', 'spec_info'
]
secret_fields = ['secret']
class AccountHistorySerializer(SecretReadableCheckMixin, serializers.ModelSerializer):
class AccountHistorySerializer(serializers.ModelSerializer):
secret_type = LabeledChoiceField(choices=SecretType.choices, label=_('Secret type'))
secret = serializers.CharField(label=_('Secret'), read_only=True)
id = serializers.IntegerField(label=_('ID'), source='history_id', read_only=True)
@@ -510,7 +509,6 @@ class AccountHistorySerializer(SecretReadableCheckMixin, serializers.ModelSerial
'history_user': {'label': _('User')},
'history_date': {'label': _('Date')},
}
secret_fields = ['secret']
class AccountTaskSerializer(serializers.Serializer):

View File

@@ -2,7 +2,7 @@ from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from accounts.models import AccountTemplate
from common.serializers import SecretReadableMixin, SecretReadableCheckMixin
from common.serializers import SecretReadableMixin
from common.serializers.fields import ObjectRelatedField
from .base import BaseAccountSerializer
@@ -62,11 +62,10 @@ class AccountDetailTemplateSerializer(AccountTemplateSerializer):
fields = AccountTemplateSerializer.Meta.fields + ['spec_info']
class AccountTemplateSecretSerializer(SecretReadableCheckMixin, SecretReadableMixin, AccountDetailTemplateSerializer):
class AccountTemplateSecretSerializer(SecretReadableMixin, AccountDetailTemplateSerializer):
class Meta(AccountDetailTemplateSerializer.Meta):
fields = AccountDetailTemplateSerializer.Meta.fields
extra_kwargs = {
**AccountDetailTemplateSerializer.Meta.extra_kwargs,
'secret': {'write_only': False},
}
secret_fields = ['secret']

View File

@@ -79,7 +79,7 @@ class VaultSignalHandler(object):
else:
vault_client.update(instance)
except Exception as e:
logger.exception('Vault save failed: %s', e)
logger.error('Vault save failed: {}'.format(e))
raise VaultException()
@staticmethod
@@ -87,7 +87,7 @@ class VaultSignalHandler(object):
try:
vault_client.delete(instance)
except Exception as e:
logger.exception('Vault delete failed: %s', e)
logger.error('Vault delete failed: {}'.format(e))
raise VaultException()

View File

@@ -6,7 +6,7 @@ from django.utils.translation import gettext_lazy as _
from accounts.backends import vault_client
from accounts.const import VaultTypeChoices
from accounts.models import AccountTemplate, Account
from accounts.models import Account, AccountTemplate
from common.utils import get_logger
from orgs.utils import tmp_to_root_org

View File

@@ -150,6 +150,7 @@ class AssetSerializer(BulkOrgResourceModelSerializer, ResourceLabelsMixin, Writa
auto_config = serializers.DictField(read_only=True, label=_('Auto info'))
platform = ObjectRelatedField(queryset=Platform.objects, required=True, label=_('Platform'),
attrs=('id', 'name', 'type'))
spec_info = serializers.DictField(read_only=True, label=_('Spec info'))
accounts_amount = serializers.IntegerField(read_only=True, label=_('Accounts amount'))
_accounts = None
@@ -164,7 +165,8 @@ class AssetSerializer(BulkOrgResourceModelSerializer, ResourceLabelsMixin, Writa
'directory_services',
]
read_only_fields = [
'accounts_amount', 'category', 'type', 'connectivity', 'auto_config',
'accounts_amount', 'category', 'type', 'connectivity',
'auto_config', 'spec_info',
'date_verified', 'created_by', 'date_created', 'date_updated',
]
fields = fields_small + fields_fk + fields_m2m + read_only_fields
@@ -186,6 +188,7 @@ class AssetSerializer(BulkOrgResourceModelSerializer, ResourceLabelsMixin, Writa
super().__init__(*args, **kwargs)
self._init_field_choices()
self._extract_accounts()
self._set_platform()
def _extract_accounts(self):
if not getattr(self, 'initial_data', None):
@@ -217,6 +220,21 @@ class AssetSerializer(BulkOrgResourceModelSerializer, ResourceLabelsMixin, Writa
protocols_data = [{'name': p.name, 'port': p.port} for p in protocols]
self.initial_data['protocols'] = protocols_data
def _set_platform(self):
if not hasattr(self, 'initial_data'):
return
platform_id = self.initial_data.get('platform')
if not platform_id:
return
if isinstance(platform_id, int) or str(platform_id).isdigit() or not isinstance(platform_id, str):
return
platform = Platform.objects.filter(name=platform_id).first()
if not platform:
return
self.initial_data['platform'] = platform.id
def _init_field_choices(self):
request = self.context.get('request')
if not request:
@@ -231,6 +249,19 @@ class AssetSerializer(BulkOrgResourceModelSerializer, ResourceLabelsMixin, Writa
return
field_type.choices = AllTypes.filter_choices(category)
@staticmethod
def get_spec_info(obj):
return {}
def get_auto_config(self, obj):
return obj.auto_config()
def get_gathered_info(self, obj):
return obj.gathered_info()
def get_accounts_amount(self, obj):
return obj.accounts_amount()
@classmethod
def setup_eager_loading(cls, queryset):
""" Perform necessary eager loading of data. """
@@ -265,8 +296,10 @@ class AssetSerializer(BulkOrgResourceModelSerializer, ResourceLabelsMixin, Writa
if not platform_id and self.instance:
platform = self.instance.platform
else:
elif isinstance(platform_id, int):
platform = Platform.objects.filter(id=platform_id).first()
else:
platform = Platform.objects.filter(name=platform_id).first()
if not platform:
raise serializers.ValidationError({'platform': _("Platform not exist")})
@@ -297,6 +330,7 @@ class AssetSerializer(BulkOrgResourceModelSerializer, ResourceLabelsMixin, Writa
def is_valid(self, raise_exception=False):
self._set_protocols_default()
self._set_platform()
return super().is_valid(raise_exception=raise_exception)
def validate_protocols(self, protocols_data):
@@ -422,7 +456,7 @@ class AssetSerializer(BulkOrgResourceModelSerializer, ResourceLabelsMixin, Writa
class DetailMixin(serializers.Serializer):
accounts = AssetAccountSerializer(many=True, required=False, label=_('Accounts'))
spec_info = MethodSerializer(label=_('Spec info'), read_only=True)
spec_info = MethodSerializer(label=_('Spec info'), read_only=True, required=False)
gathered_info = MethodSerializer(label=_('Gathered info'), read_only=True)
auto_config = serializers.DictField(read_only=True, label=_('Auto info'))

View File

@@ -219,18 +219,8 @@ class RDPFileClientProtocolURLMixin:
}
})
else:
if connect_method_dict['type'] == 'virtual_app':
endpoint_protocol = 'vnc'
token_protocol = 'vnc'
data.update({
'protocol': 'vnc',
})
else:
endpoint_protocol = connect_method_dict['endpoint_protocol']
token_protocol = token.protocol
endpoint = self.get_smart_endpoint(
protocol=endpoint_protocol,
protocol=connect_method_dict['endpoint_protocol'],
asset=asset
)
data.update({
@@ -246,7 +236,7 @@ class RDPFileClientProtocolURLMixin:
},
'endpoint': {
'host': endpoint.host,
'port': endpoint.get_port(token.asset, token_protocol),
'port': endpoint.get_port(token.asset, token.protocol),
}
})
return data

View File

@@ -108,7 +108,7 @@ class SessionAuthentication(authentication.SessionAuthentication):
user = getattr(request._request, 'user', None)
# Unauthenticated, CSRF validation not required
if not user or not user.is_active or not user.is_valid:
if not user or not user.is_active:
return None
try:

View File

@@ -36,7 +36,7 @@ class MFAMiddleware:
# 这个是 mfa 登录页需要的请求, 也得放出来, 用户其实已经在 CAS/OIDC 中完成登录了
white_urls = [
'login/mfa', 'mfa/select', 'face/context', 'jsi18n/', '/static/',
'/profile/otp', '/logout/', '/media/'
'/profile/otp', '/logout/',
]
for url in white_urls:
if request.path.find(url) > -1:

View File

@@ -67,6 +67,7 @@ class UserLoginMFAView(mixins.AuthMixin, FormView):
def get_context_data(self, **kwargs):
user = self.get_user_from_session()
mfa_context = self.get_user_mfa_context(user)
print(mfa_context)
kwargs.update(mfa_context)
return kwargs

View File

@@ -1,12 +1,9 @@
import os
from ctypes import *
from .exception import PiicoError
from .session import Session
from .cipher import *
from .digest import *
from django.core.cache import cache
from redis_lock import Lock as RedisLock
class Device:
@@ -74,28 +71,10 @@ class Device:
self.__device = device
def __reset_key_store(self):
redis_client = cache.client.get_client()
server_hostname = os.environ.get("SERVER_HOSTNAME")
RESET_LOCK_KEY = f"spiico:{server_hostname}:reset"
LOCK_EXPIRE_SECONDS = 300
if self._driver is None:
raise PiicoError("no driver loaded", 0)
if self.__device is None:
raise PiicoError("device not open", 0)
# ---- 分布式锁Redis-Lock 实现 Redlock ----
lock = RedisLock(
redis_client,
RESET_LOCK_KEY,
expire=LOCK_EXPIRE_SECONDS, # 锁自动过期
auto_renewal=False, # 不自动续租
)
# 尝试获取锁,拿不到直接返回
if not lock.acquire(blocking=False):
return
# ---- 真正执行 reset ----
ret = self._driver.SPII_ResetModule(self.__device)
if ret != 0:
raise PiicoError("reset device failed", ret)
raise PiicoError("reset device failed", ret)

View File

@@ -30,30 +30,12 @@ __all__ = [
"CommonSerializerMixin",
"CommonBulkSerializerMixin",
"SecretReadableMixin",
"SecretReadableCheckMixin",
"CommonModelSerializer",
"CommonBulkModelSerializer",
"ResourceLabelsMixin",
]
class SecretReadableCheckMixin(serializers.Serializer):
"""
根据 SECURITY_ACCOUNT_SECRET_READ 配置控制密码字段的可读性
当配置为 False 时,密码字段返回 None
"""
def to_representation(self, instance):
ret = super().to_representation(instance)
if not settings.SECURITY_ACCOUNT_SECRET_READ:
secret_fields = getattr(self.Meta, 'secret_fields', ['secret'])
for field_name in secret_fields:
if field_name in ret:
ret[field_name] = '<REDACTED>'
return ret
class SecretReadableMixin(serializers.Serializer):
"""加密字段 (EncryptedField) 可读性"""

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1636,8 +1636,7 @@
"setVariable": "Set variable",
"userId": "User ID",
"userName": "User name",
"AccountSecretReadDisabled": "Account secret reading has been disabled by administrator",
"AccessToken": "Access tokens",
"AccessTokenTip": "Access Token is a temporary credential generated through the OAuth2 (Authorization Code Grant) flow using the JumpServer client, which is used to access protected resources.",
"Revoke": "Revoke"
}
}

View File

@@ -8,8 +8,6 @@
"AccessDistribution": "Distribución de visitas",
"AccessIP": "Lista blanca de IP",
"AccessKey": "Clave de acceso",
"AccessToken": "Token de acceso",
"AccessTokenTip": "El token de acceso es un certificado temporal generado a través del cliente JumpServer utilizando el flujo de OAuth2 (autorización por código) para acceder a recursos protegidos.",
"Account": "Información de la cuenta",
"AccountActivities": "Actividad de la cuenta",
"AccountAndPasswordChangeRank": "Clasificación de cambio de contraseña de cuenta",
@@ -46,7 +44,6 @@
"AccountPushUpdate": "Actualizar la notificación de la cuenta",
"AccountReport": "Informe de cuentas",
"AccountResult": "Cambio de contraseña de cuenta exitoso/fallido",
"AccountSecretReadDisabled": "La función de lectura de nombre de usuario y contraseña ha sido desactivada por el administrador",
"AccountSelectHelpText": "La lista de cuentas agrega el nombre de usuario de la cuenta. Tipo de contraseña.",
"AccountSessions": "Sesión de cuenta",
"AccountStatisticsReport": "Informe estadístico de cuentas",
@@ -282,7 +279,6 @@
"CACertificate": "Certificado CA",
"CAS": "CAS",
"CMPP2": "CMPP v2.0",
"CTYun": "Tianyi Cloud",
"CTYunPrivate": "eCloud Nube Privada",
"CalculationResults": "Error en la expresión cron",
"CallRecords": "Registro de llamadas",
@@ -1180,8 +1176,6 @@
"RetrySelected": "Reintentar selección",
"Review": "Revisión",
"Reviewer": "Aprobador",
"Revoke": "Revocar",
"Risk": "Riesgo",
"RiskDetection": "Detección de riesgos",
"RiskDetectionDetail": "Detalles de detección de riesgos",
"RiskyAccount": "Cuenta de riesgo",
@@ -1645,7 +1639,6 @@
"overwriteProtocolsAndPortsMsg": "Esta acción reemplazará todos los protocolos y puertos, ¿continuar?",
"pleaseSelectAssets": "Por favor, seleccione un activo.",
"removeWarningMsg": "¿Está seguro de que desea eliminar?",
"selectFiles": "Se ha seleccionado el archivo número {number}",
"selectedAssets": "Activos seleccionados",
"setVariable": "configurar parámetros",
"userId": "ID de usuario",

View File

@@ -8,8 +8,6 @@
"AccessDistribution": "アクセス分布",
"AccessIP": "IP ホワイトリスト",
"AccessKey": "アクセスキー",
"AccessToken": "アクセス・トークン",
"AccessTokenTip": "アクセス・トークンは、JumpServer クライアントを通じて OAuth2Authorization Code Grantフローを使用して生成される一時的な証明書であり、保護されたリソースへのアクセスに使用されます。",
"Account": "アカウント情報",
"AccountActivities": "アカウント活動",
"AccountAmount": "アカウント数",
@@ -48,7 +46,6 @@
"AccountPushUpdate": "アカウント更新プッシュ",
"AccountReport": "アカウントレポート",
"AccountResult": "アカウントパスワード変更成功/失敗",
"AccountSecretReadDisabled": "アカウントパスワードの読み取り機能は管理者によって無効になっています。",
"AccountSelectHelpText": "アカウント一覧に追加されている内容は、アカウントのユーザー名",
"AccountSessions": " アカウントセッション ",
"AccountStatisticsReport": "アカウント統計レポート",
@@ -286,7 +283,6 @@
"CACertificate": "CA 証明書",
"CAS": "CAS",
"CMPP2": "CMPP v2.0",
"CTYun": "天翼クラウド",
"CTYunPrivate": "イークラウド・プライベートクラウド",
"CalculationResults": "cron 式のエラー",
"CallRecords": "つうわきろく",
@@ -1185,8 +1181,6 @@
"RetrySelected": "選択したものを再試行",
"Review": "審査",
"Reviewer": "承認者",
"Revoke": "取り消し",
"Risk": "リスク",
"RiskDetection": "リスク検出",
"RiskDetectionDetail": "リスク検出の詳細",
"RiskyAccount": "リスクアカウント",
@@ -1650,7 +1644,6 @@
"overwriteProtocolsAndPortsMsg": "この操作はすべてのプロトコルとポートを上書きしますが、続行してよろしいですか?",
"pleaseSelectAssets": "資産を選択してください",
"removeWarningMsg": "削除してもよろしいですか",
"selectFiles": "{number}ファイルを選択しました。",
"selectedAssets": "選択した資産",
"setVariable": "パラメータ設定",
"userId": "ユーザーID",

View File

@@ -8,8 +8,6 @@
"AccessDistribution": "방문 분포",
"AccessIP": "IP 화이트리스트",
"AccessKey": "액세스 키",
"AccessToken": "접속 토큰",
"AccessTokenTip": "접속 토큰은 JumpServer 클라이언트를 통해 OAuth2(인증 코드 인증) 프로세스를 사용하여 생성된 임시 자격 증명으로, 보호된 리소스에 접근하는 데 사용됩니다.",
"Account": "계정",
"AccountActivities": "계정 활동",
"AccountAndPasswordChangeRank": "계정 비밀번호 변경 순위",
@@ -46,7 +44,6 @@
"AccountPushUpdate": "계정 업데이트 푸시",
"AccountReport": "계정 보고서",
"AccountResult": "계정 비밀번호 변경 성공/실패",
"AccountSecretReadDisabled": "계정 비밀번호 읽기 기능은 관리자가 비활성화하였습니다.",
"AccountSelectHelpText": "계정 목록에 추가하는 것은 계정의 사용자 이름—암호 유형입니다.",
"AccountSessions": "계정 세션",
"AccountStatisticsReport": "계정 통계 보고서",
@@ -282,7 +279,6 @@
"CACertificate": "CA 인증서",
"CAS": "CAS",
"CMPP2": "CMPP v2.0",
"CTYun": "천윳 클라우드",
"CTYunPrivate": "천翼 개인 클라우드",
"CalculationResults": "cron 표현식 오류",
"CallRecords": "호출 기록",
@@ -1180,8 +1176,6 @@
"RetrySelected": "선택한 항목 재시도",
"Review": "검토",
"Reviewer": "승인자",
"Revoke": "취소",
"Risk": "위험",
"RiskDetection": "위험 감지",
"RiskDetectionDetail": "위험 감지 상세 정보",
"RiskyAccount": "위험 계정",
@@ -1645,7 +1639,6 @@
"overwriteProtocolsAndPortsMsg": "이 작업은 모든 프로토콜과 포트를 덮어씌우게 됩니다. 계속하시겠습니까?",
"pleaseSelectAssets": "자산을 선택해 주세요",
"removeWarningMsg": "제거할 것인지 확실합니까?",
"selectFiles": "선택한 파일 {number}개",
"selectedAssets": "선택한 자산",
"setVariable": "설정 매개변수",
"userId": "사용자 ID",

View File

@@ -8,8 +8,6 @@
"AccessDistribution": "Distribuição de Acesso",
"AccessIP": "Lista branca de IP",
"AccessKey": "Chave de Acesso",
"AccessToken": "Token de acesso",
"AccessTokenTip": "O token de acesso é um credencial temporário gerado pelo cliente JumpServer usando o fluxo OAuth2 (autorização por código), utilizado para acessar recursos protegidos.",
"Account": "Informações da conta",
"AccountActivities": "Atividades da conta",
"AccountAndPasswordChangeRank": "Alteração de Senha por Classificação",
@@ -46,7 +44,6 @@
"AccountPushUpdate": " Atualização de notificação de conta ",
"AccountReport": "Relatório de Contas",
"AccountResult": "Alteração de senha da conta bem-sucedida/falhada",
"AccountSecretReadDisabled": "A funcionalidade de leitura de nome de usuário e senha foi desativada pelo administrador",
"AccountSelectHelpText": "A lista de contas inclui o nome de usuário da conta",
"AccountSessions": "Conta de sessão ",
"AccountStatisticsReport": "Relatório de Estatísticas de Contas",
@@ -283,7 +280,6 @@
"CACertificate": " Certificado CA",
"CAS": "CAS",
"CMPP2": "CMPP v2.0",
"CTYun": "Tianyi Cloud",
"CTYunPrivate": " eCloud Nuvem Privada",
"CalculationResults": "Erro de expressão cron",
"CallRecords": "Registro de chamadas",
@@ -1181,8 +1177,6 @@
"RetrySelected": "repetir a seleção",
"Review": "Revisar",
"Reviewer": "Aprovador",
"Revoke": "Revogar",
"Risk": "Risco",
"RiskDetection": " Detecção de risco ",
"RiskDetectionDetail": "Detalhes da Detecção de Risco",
"RiskyAccount": " Conta de risco ",
@@ -1646,7 +1640,6 @@
"overwriteProtocolsAndPortsMsg": "Esta ação substituirá todos os protocolos e portas. Deseja continuar?",
"pleaseSelectAssets": "Por favor, selecione um ativo.",
"removeWarningMsg": "Tem certeza de que deseja remover",
"selectFiles": "Foram selecionados {number} arquivos",
"selectedAssets": "Ativos selecionados",
"setVariable": "Parâmetros de configuração",
"userId": "ID do usuário",

View File

@@ -8,8 +8,6 @@
"AccessDistribution": "Распределение доступа",
"AccessIP": "Белый список IP",
"AccessKey": "Ключ доступа",
"AccessToken": "Токен доступа",
"AccessTokenTip": "Токен доступа создается через клиент JumpServer с использованием процесса OAuth2 (авторизация через код) в качестве временного удостоверения для доступа к защищенным ресурсам.",
"Account": "Информация об УЗ",
"AccountActivities": "Активность учетной записи",
"AccountAndPasswordChangeRank": "Рейтинг изменений паролей и учётных записей",
@@ -46,7 +44,6 @@
"AccountPushUpdate": "Обновление УЗ для публикации",
"AccountReport": "Отчет по УЗ",
"AccountResult": "Успешное или неудачное изменение секрета УЗ",
"AccountSecretReadDisabled": "Функция чтения логина и пароля была отключена администратором",
"AccountSelectHelpText": "В списке учетных записей отображается имя пользователя",
"AccountSessions": "Сессии учетной записи",
"AccountStatisticsReport": "Отчет по учетным записям",
@@ -282,7 +279,6 @@
"CACertificate": "Сертификат ЦС",
"CAS": "CAS",
"CMPP2": "CMPP v2.0",
"CTYun": "Tianyi Cloud",
"CTYunPrivate": "eCloud Private Cloud",
"CalculationResults": "Ошибка в выражении cron",
"CallRecords": "Запись вызовов",
@@ -1180,8 +1176,6 @@
"RetrySelected": "Повторить выбранное",
"Review": "Требовать одобрения",
"Reviewer": "Утверждающий",
"Revoke": "Отмена",
"Risk": "Риск",
"RiskDetection": "Выявление рисков",
"RiskDetectionDetail": "Детали обнаружения риска",
"RiskyAccount": "УЗ с риском",
@@ -1645,7 +1639,6 @@
"overwriteProtocolsAndPortsMsg": "Это действие заменит все протоколы и порты. Продолжить?",
"pleaseSelectAssets": "Пожалуйста, выберите актив",
"removeWarningMsg": "Вы уверены, что хотите удалить",
"selectFiles": "Выбрано {number} файлов",
"selectedAssets": "Выбранные активы",
"setVariable": "Задать переменную",
"userId": "ID пользователя",

View File

@@ -8,8 +8,6 @@
"AccessDistribution": "Phân bố truy cập",
"AccessIP": "Danh sách trắng IP",
"AccessKey": "Khóa truy cập",
"AccessToken": "Mã thông hành",
"AccessTokenTip": "Mã thông hành là giấy chứng nhận tạm thời được tạo ra thông qua quy trình OAuth2 (ủy quyền mã) sử dụng khách hàng JumpServer, dùng để truy cập tài nguyên được bảo vệ.",
"Account": "Tài khoản",
"AccountActivities": "Tài khoản hoạt động",
"AccountAndPasswordChangeRank": "Thay đổi mật khẩu tài khoản xếp hạng",
@@ -46,7 +44,6 @@
"AccountPushUpdate": "Cập nhật thông tin tài khoản",
"AccountReport": "Báo cáo tài khoản",
"AccountResult": "Thay đổi mật khẩu tài khoản thành công/thất bại",
"AccountSecretReadDisabled": "Chức năng đọc tài khoản mật khẩu đã bị quản lý bởi quản trị viên vô hiệu hóa",
"AccountSelectHelpText": "Danh sách tài khoản thêm tên người dùng của tài khoản",
"AccountSessions": "Phiên tài khoản",
"AccountStatisticsReport": "Báo cáo thống kê tài khoản",
@@ -282,7 +279,6 @@
"CACertificate": "Chứng chỉ CA",
"CAS": "CAS",
"CMPP2": "CMPP v2.0",
"CTYun": "Điện toán đám mây Thiên Vân",
"CTYunPrivate": "Đám mây riêng Tianyi",
"CalculationResults": "Biểu thức cron sai",
"CallRecords": "Ghi chép gọi",
@@ -1180,8 +1176,6 @@
"RetrySelected": "Thử lại đã chọn",
"Review": "Xem xét",
"Reviewer": "Người phê duyệt",
"Revoke": "Huỷ bỏ",
"Risk": "Rủi ro",
"RiskDetection": "Phát hiện rủi ro",
"RiskDetectionDetail": "Chi tiết phát hiện rủi ro",
"RiskyAccount": "Tài khoản rủi ro",
@@ -1645,7 +1639,6 @@
"overwriteProtocolsAndPortsMsg": "Hành động này sẽ ghi đè lên tất cả các giao thức và cổng, có tiếp tục không?",
"pleaseSelectAssets": "Vui lòng chọn tài sản.",
"removeWarningMsg": "Bạn có chắc chắn muốn xóa bỏ?",
"selectFiles": "Đã chọn chọn {number} tệp tin",
"selectedAssets": "Tài sản đã chọn",
"setVariable": "Cài đặt tham số",
"userId": "ID người dùng",

View File

@@ -1648,6 +1648,5 @@
"selectFiles": "已选择选择{number}文件",
"AccessToken": "访问令牌",
"AccessTokenTip": "访问令牌是通过 JumpServer 客户端使用 OAuth2授权码授权流程生成的临时凭证用于访问受保护的资源。",
"Revoke": "撤销",
"AccountSecretReadDisabled": "账号密码读取功能已被管理员禁用"
}
"Revoke": "撤销"
}

View File

@@ -8,8 +8,6 @@
"AccessDistribution": "訪問分布",
"AccessIP": "IP 白名單",
"AccessKey": "訪問金鑰",
"AccessToken": "訪問令牌",
"AccessTokenTip": "訪問令牌是透過 JumpServer 客戶端使用 OAuth2授權碼授權流程生成的臨時憑證用於訪問受保護的資源。",
"Account": "雲帳號",
"AccountActivities": "帳號活動",
"AccountAmount": "帳號數量",
@@ -48,7 +46,6 @@
"AccountPushUpdate": "更新帳號推送",
"AccountReport": "帳號報表",
"AccountResult": "帳號改密成功/失敗",
"AccountSecretReadDisabled": "帳號密碼讀取功能已被管理員禁用",
"AccountSelectHelpText": "帳號清單所加入的是使用者名稱",
"AccountSessions": "帳號會話",
"AccountStatisticsReport": "帳號統計報告",
@@ -286,7 +283,6 @@
"CACertificate": "CA 證書",
"CAS": "CAS",
"CMPP2": "CMPP v2.0",
"CTYun": "天翼雲",
"CTYunPrivate": "天翼私有雲",
"CalculationResults": "呼叫記錄",
"CallRecords": "調用記錄",
@@ -1185,8 +1181,6 @@
"RetrySelected": "重新嘗試所選",
"Review": "審查",
"Reviewer": "審批人",
"Revoke": "撤銷",
"Risk": "風險",
"RiskDetection": "風險檢測",
"RiskDetectionDetail": "風險檢測詳情",
"RiskyAccount": "風險帳號",
@@ -1650,7 +1644,6 @@
"overwriteProtocolsAndPortsMsg": "此操作將覆蓋所有協議和端口,是否繼續?",
"pleaseSelectAssets": "請選擇資產",
"removeWarningMsg": "你確定要移除",
"selectFiles": "已選擇{number}文件",
"selectedAssets": "已選資產",
"setVariable": "設置參數",
"userId": "用戶ID",

View File

@@ -288,6 +288,5 @@
"start time": "Start time",
"success": "Success",
"system user": "System user",
"user": "User",
"tabLimits": "15 tabs are currently open.\nTo ensure system stability, would you like to open Luna in a new browser tab to continue?"
"user": "User"
}

View File

@@ -288,6 +288,5 @@
"start time": "Hora de inicio",
"success": "Éxito",
"system user": "Usuario del sistema",
"tabLimits": "Actualmente tienes 15 pestañas abiertas. \n¿Para garantizar la estabilidad del sistema, deberías abrir Luna en una nueva pestaña del navegador para continuar con la operación?",
"user": "Usuario"
}

View File

@@ -288,6 +288,5 @@
"start time": "開始時間",
"success": "成功",
"system user": "システムユーザー",
"tabLimits": "現在、15個のタブが開かれています。システムの安定性を確保するために、新しいブラウザのタブでLunaを開いて操作を続けますか",
"user": "ユーザー"
}

View File

@@ -288,6 +288,5 @@
"start time": "시작 시간",
"success": "성공",
"system user": "시스템 사용자",
"tabLimits": "현재 15개의 탭이 열려 있습니다. 시스템의 안정성을 위해 Luna를 계속 사용하려면 새로운 브라우저 탭에서 열어보시겠습니까?",
"user": "사용자"
}

View File

@@ -288,6 +288,5 @@
"start time": "Hora de início",
"success": " Sucesso",
"system user": "Usuário do Sistema",
"tabLimits": "Atualmente, 15 abas estão abertas. Para garantir a estabilidade do sistema, você deseja abrir o Luna em uma nova aba do navegador para continuar a operação?",
"user": "Usuário"
}

View File

@@ -288,6 +288,5 @@
"start time": "время начала",
"success": "успешно",
"system user": "системный пользователь",
"tabLimits": "В данный момент открыто 15 вкладок. \nЧтобы обеспечить стабильность системы, стоит ли открыть Luna в новой вкладке браузера для продолжения работы?",
"user": "пользователь"
}

View File

@@ -288,6 +288,5 @@
"start time": "Thời gian bắt đầu",
"success": "Thành công",
"system user": "Tên đăng nhập",
"tabLimits": "Hiện tại đã mở 15 tab. \nĐể đảm bảo tính ổn định của hệ thống, có qua tab trình duyệt mới để mở Luna và tiếp tục thao tác không?",
"user": "Người dùng"
}

View File

@@ -288,6 +288,5 @@
"start time": "开始时间",
"success": "成功",
"system user": "系统用户",
"user": "用户",
"tabLimits": "当前已打开 15 个标签页。\n为保证系统稳定是否在新的浏览器标签页中打开 Luna 以继续操作?"
"user": "用户"
}

View File

@@ -289,6 +289,5 @@
"start time": "開始時間",
"success": "成功",
"system user": "系統用戶",
"tabLimits": "當前已打開 15 個標籤頁。 \n為了確保系統穩定是否在新的瀏覽器標籤頁中打開 Luna 以繼續操作?",
"user": "用戶"
}

View File

@@ -1,4 +1,3 @@
from .aggregate import *
from .dashboard import IndexApi
from .health import PrometheusMetricsApi, HealthCheckView
from .search import GlobalSearchView

View File

@@ -1,9 +0,0 @@
from .detail import ResourceDetailApi
from .list import ResourceListApi
from .supported import ResourceTypeListApi
__all__ = [
'ResourceListApi',
'ResourceDetailApi',
'ResourceTypeListApi',
]

View File

@@ -1,57 +0,0 @@
list_params = [
{
"name": "search",
"in": "query",
"description": "A search term.",
"required": False,
"type": "string"
},
{
"name": "order",
"in": "query",
"description": "Which field to use when ordering the results.",
"required": False,
"type": "string"
},
{
"name": "limit",
"in": "query",
"description": "Number of results to return per page. Default is 10.",
"required": False,
"type": "integer"
},
{
"name": "offset",
"in": "query",
"description": "The initial index from which to return the results.",
"required": False,
"type": "integer"
},
]
common_params = [
{
"name": "resource",
"in": "path",
"description": """Resource to query, e.g. users, assets, permissions, acls, user-groups, policies, nodes, hosts,
devices, clouds, webs, databases,
gpts, ds, customs, platforms, zones, gateways, protocol-settings, labels, virtual-accounts,
gathered-accounts, account-templates, account-template-secrets, account-backups, account-backup-executions,
change-secret-automations, change-secret-executions, change-secret-records, gather-account-automations,
gather-account-executions, push-account-automations, push-account-executions, push-account-records,
check-account-automations, check-account-executions, account-risks, integration-apps, asset-permissions,
zones, gateways, virtual-accounts, gathered-accounts, account-templates, account-template-secrets,,
GET /api/v1/resources/ to get full supported resource.
""",
"required": True,
"type": "string"
},
{
"name": "X-JMS-ORG",
"in": "header",
"description": "The organization ID to use for the request. Organization is the namespace for resources, if not set, use default org",
"required": False,
"type": "string"
}
]

View File

@@ -1,75 +0,0 @@
# views.py
from drf_spectacular.utils import extend_schema, OpenApiParameter
from rest_framework.permissions import IsAuthenticated
from rest_framework.views import APIView
from .const import common_params
from .proxy import ProxyMixin
from .utils import param_dic_to_param
one_param = [
{
'name': 'id',
'in': 'path',
'required': True,
'description': 'Resource ID',
'type': 'string',
}
]
object_params = [
param_dic_to_param(d)
for d in common_params + one_param
]
class ResourceDetailApi(ProxyMixin, APIView):
permission_classes = [IsAuthenticated]
@extend_schema(
operation_id="get_resource_detail",
summary="Get resource detail",
parameters=object_params,
description="""
Get resource detail.
{resource} is the resource name, GET /api/v1/resources/ to get full supported resource.
""",
)
def get(self, request, resource, pk=None):
return self._proxy(request, resource, pk=pk, action='retrieve')
@extend_schema(
operation_id="delete_resource",
summary="Delete the resource",
parameters=object_params,
description="Delete the resource, and can not be restored",
)
def delete(self, request, resource, pk=None):
return self._proxy(request, resource, pk, action='destroy')
@extend_schema(
operation_id="update_resource",
summary="Update the resource property",
parameters=object_params,
description="""
Update the resource property, all property will be update,
{resource} is the resource name, GET /api/v1/resources/ to get full supported resource.
OPTION /api/v1/resources/{resource}/{id}/?action=put to get field type and helptext.
""",
)
def put(self, request, resource, pk=None):
return self._proxy(request, resource, pk, action='update')
@extend_schema(
operation_id="partial_update_resource",
summary="Update the resource property",
parameters=object_params,
description="""
Partial update the resource property, only request property will be update,
OPTION /api/v1/resources/{resource}/{id}/?action=patch to get field type and helptext.
""",
)
def patch(self, request, resource, pk=None):
return self._proxy(request, resource, pk, action='partial_update')

View File

@@ -1,87 +0,0 @@
# views.py
from drf_spectacular.utils import extend_schema
from rest_framework.routers import DefaultRouter
from rest_framework.views import APIView
from .const import list_params, common_params
from .proxy import ProxyMixin
from .utils import param_dic_to_param
router = DefaultRouter()
BASE_URL = "http://localhost:8080"
list_params = [
param_dic_to_param(d)
for d in list_params + common_params
]
create_params = [
param_dic_to_param(d)
for d in common_params
]
list_schema = {
"required": [
"count",
"results"
],
"type": "object",
"properties": {
"count": {
"type": "integer"
},
"next": {
"type": "string",
"format": "uri",
"x-nullable": True
},
"previous": {
"type": "string",
"format": "uri",
"x-nullable": True
},
"results": {
"type": "array",
"items": {
}
}
}
}
from drf_spectacular.openapi import OpenApiResponse, OpenApiExample
class ResourceListApi(ProxyMixin, APIView):
@extend_schema(
operation_id="get_resource_list",
summary="Get resource list",
parameters=list_params,
responses={200: OpenApiResponse(description="Resource list response")},
description="""
Get resource list, you should set the resource name in the url.
OPTIONS /api/v1/resources/{resource}/?action=get to get every type resource's field type and help text.
""",
)
# ↓↓↓ Swagger 自动文档 ↓↓↓
def get(self, request, resource):
return self._proxy(request, resource)
@extend_schema(
operation_id="create_resource_by_type",
summary="Create resource",
parameters=create_params,
description="""
Create resource,
OPTIONS /api/v1/resources/{resource}/?action=post to get every resource type field type and helptext, and
you will know how to create it.
""",
)
def post(self, request, resource, pk=None):
if not resource:
resource = request.data.pop('resource', '')
return self._proxy(request, resource, pk, action='create')
def options(self, request, resource, pk=None):
return self._proxy(request, resource, pk, action='metadata')

View File

@@ -1,75 +0,0 @@
# views.py
from urllib.parse import urlencode
import requests
from rest_framework.exceptions import NotFound, APIException
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.routers import DefaultRouter
from rest_framework.views import APIView
from .utils import get_full_resource_map
router = DefaultRouter()
BASE_URL = "http://localhost:8080"
class ProxyMixin(APIView):
"""
通用资源代理 API支持动态路径、自动文档生成
"""
permission_classes = [IsAuthenticated]
def _build_url(self, resource_name: str, pk: str = None, query_params=None):
resource_map = get_full_resource_map()
resource = resource_map.get(resource_name)
if not resource:
raise NotFound(f"Unknown resource: {resource_name}")
base_path = resource['path']
if pk:
base_path += f"{pk}/"
if query_params:
base_path += f"?{urlencode(query_params)}"
return f"{BASE_URL}{base_path}"
def _proxy(self, request, resource: str, pk: str = None, action='list'):
method = request.method.lower()
if method not in ['get', 'post', 'put', 'patch', 'delete', 'options']:
raise APIException("Unsupported method")
if not resource or resource == '{resource}':
if request.data:
resource = request.data.get('resource')
query_params = request.query_params.dict()
if action == 'list':
query_params['limit'] = 10
url = self._build_url(resource, pk, query_params)
headers = {k: v for k, v in request.headers.items() if k.lower() != 'host'}
cookies = request.COOKIES
body = request.body if method in ['post', 'put', 'patch'] else None
try:
resp = requests.request(
method=method,
url=url,
headers=headers,
cookies=cookies,
data=body,
timeout=10,
)
content_type = resp.headers.get('Content-Type', '')
if 'application/json' in content_type:
data = resp.json()
else:
data = resp.text # 或者 bytesresp.content
return Response(data=data, status=resp.status_code)
except requests.RequestException as e:
raise APIException(f"Proxy request failed: {str(e)}")

View File

@@ -1,45 +0,0 @@
# views.py
from drf_spectacular.utils import extend_schema
from rest_framework import serializers
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.routers import DefaultRouter
from rest_framework.views import APIView
from .utils import get_full_resource_map
router = DefaultRouter()
BASE_URL = "http://localhost:8080"
class ResourceTypeResourceSerializer(serializers.Serializer):
name = serializers.CharField()
path = serializers.CharField()
app = serializers.CharField()
verbose_name = serializers.CharField()
description = serializers.CharField()
class ResourceTypeListApi(APIView):
permission_classes = [IsAuthenticated]
@extend_schema(
operation_id="get_supported_resources",
summary="Get-all-support-resources",
description="Get all support resources, name, path, verbose_name description",
responses={200: ResourceTypeResourceSerializer(many=True)}, # Specify the response serializer
)
def get(self, request):
result = []
resource_map = get_full_resource_map()
for name, desc in resource_map.items():
desc = resource_map.get(name, {})
resource = {
"name": name,
**desc,
"path": f'/api/v1/resources/{name}/',
}
result.append(resource)
return Response(result)

View File

@@ -1,128 +0,0 @@
# views.py
import re
from functools import lru_cache
from typing import Dict
from django.urls import URLPattern
from django.urls import URLResolver
from drf_spectacular.utils import OpenApiParameter
from rest_framework.routers import DefaultRouter
router = DefaultRouter()
BASE_URL = "http://localhost:8080"
def clean_path(path: str) -> str:
"""
清理掉 DRF 自动生成的正则格式内容,让其变成普通 RESTful URL path。
"""
# 去掉格式后缀匹配: \.(?P<format>xxx)
path = re.sub(r'\\\.\(\?P<format>[^)]+\)', '', path)
# 去掉括号式格式匹配
path = re.sub(r'\(\?P<format>[^)]+\)', '', path)
# 移除 DRF 中正则参数的部分 (?P<param>pattern)
path = re.sub(r'\(\?P<\w+>[^)]+\)', '{param}', path)
# 如果有多个括号包裹的正则(比如前缀路径),去掉可选部分包装
path = re.sub(r'\(\(([^)]+)\)\?\)', r'\1', path) # ((...))? => ...
# 去掉中间和两边的 ^ 和 $
path = path.replace('^', '').replace('$', '')
# 去掉尾部 ?/
path = re.sub(r'\?/?$', '', path)
# 去掉反斜杠
path = path.replace('\\', '')
# 替换多重斜杠
path = re.sub(r'/+', '/', path)
# 添加开头斜杠,移除多余空格
path = path.strip()
if not path.startswith('/'):
path = '/' + path
if not path.endswith('/'):
path += '/'
return path
def extract_resource_paths(urlpatterns, prefix='/api/v1/') -> Dict[str, Dict[str, str]]:
resource_map = {}
for pattern in urlpatterns:
if isinstance(pattern, URLResolver):
nested_prefix = prefix + str(pattern.pattern)
resource_map.update(extract_resource_paths(pattern.url_patterns, nested_prefix))
elif isinstance(pattern, URLPattern):
callback = pattern.callback
actions = getattr(callback, 'actions', {})
if not actions:
continue
if 'get' in actions and actions['get'] == 'list':
path = clean_path(prefix + str(pattern.pattern))
# 尝试获取资源名称
name = pattern.name
if name and name.endswith('-list'):
resource = name[:-5]
else:
resource = path.strip('/').split('/')[-1]
# 不强行加 s资源名保持原状即可
resource = resource if resource.endswith('s') else resource + 's'
# 获取 View 类和 model 的 verbose_name
view_cls = getattr(callback, 'cls', None)
model = None
if view_cls:
queryset = getattr(view_cls, 'queryset', None)
if queryset is not None:
model = getattr(queryset, 'model', None)
else:
# 有些 View 用 get_queryset()
try:
instance = view_cls()
qs = instance.get_queryset()
model = getattr(qs, 'model', None)
except Exception:
pass
if not model:
continue
app = str(getattr(model._meta, 'app_label', ''))
verbose_name = str(getattr(model._meta, 'verbose_name', ''))
resource_map[resource] = {
'path': path,
'app': app,
'verbose_name': verbose_name,
'description': model.__doc__.__str__()
}
print("Extracted resource paths:", list(resource_map.keys()))
return resource_map
def param_dic_to_param(d):
return OpenApiParameter(
name=d['name'], location=d['in'],
description=d['description'], type=d['type'], required=d.get('required', False)
)
@lru_cache()
def get_full_resource_map():
from apps.jumpserver.urls import resource_api
resource_map = extract_resource_paths(resource_api)
print("Building URL for resource:", resource_map)
return resource_map

View File

@@ -575,7 +575,6 @@ class Config(dict):
],
'SECURITY_SERVICE_ACCOUNT_REGISTRATION': 'auto',
'SECURITY_VIEW_AUTH_NEED_MFA': True,
'SECURITY_ACCOUNT_SECRET_READ': True,
'SECURITY_MAX_IDLE_TIME': 30,
'SECURITY_MAX_SESSION_TIME': 24,
'SECURITY_PASSWORD_EXPIRATION_TIME': 9999,
@@ -699,21 +698,10 @@ class Config(dict):
'LIMIT_SUPER_PRIV': False,
# Chat AI
'IS_CUSTOM_MODEL': False,
'CHAT_AI_ENABLED': False,
'CHAT_AI_METHOD': 'api',
'CHAT_AI_EMBED_URL': '',
'CHAT_AI_TYPE': 'gpt',
'GPT_BASE_URL': '',
'GPT_API_KEY': '',
'GPT_PROXY': '',
'GPT_MODEL': 'gpt-4o-mini',
'CUSTOM_GPT_MODEL': 'gpt-4o-mini',
'DEEPSEEK_BASE_URL': '',
'DEEPSEEK_API_KEY': '',
'DEEPSEEK_PROXY': '',
'DEEPSEEK_MODEL': 'deepseek-chat',
'CUSTOM_DEEPSEEK_MODEL': 'deepseek-chat',
'CHAT_AI_PROVIDERS': [],
'VIRTUAL_APP_ENABLED': False,
'FILE_UPLOAD_SIZE_LIMIT_MB': 200,

View File

@@ -5,6 +5,7 @@ from rest_framework.pagination import LimitOffsetPagination
class MaxLimitOffsetPagination(LimitOffsetPagination):
max_limit = settings.MAX_PAGE_SIZE
default_limit = settings.DEFAULT_PAGE_SIZE
def get_count(self, queryset):
try:

View File

@@ -60,7 +60,6 @@ VERIFY_CODE_TTL = CONFIG.VERIFY_CODE_TTL
SECURITY_MFA_VERIFY_TTL = CONFIG.SECURITY_MFA_VERIFY_TTL
SECURITY_UNCOMMON_USERS_TTL = CONFIG.SECURITY_UNCOMMON_USERS_TTL
SECURITY_VIEW_AUTH_NEED_MFA = CONFIG.SECURITY_VIEW_AUTH_NEED_MFA
SECURITY_ACCOUNT_SECRET_READ = CONFIG.SECURITY_ACCOUNT_SECRET_READ
SECURITY_SERVICE_ACCOUNT_REGISTRATION = CONFIG.SECURITY_SERVICE_ACCOUNT_REGISTRATION
SECURITY_LOGIN_CAPTCHA_ENABLED = CONFIG.SECURITY_LOGIN_CAPTCHA_ENABLED
SECURITY_MFA_IN_LOGIN_PAGE = CONFIG.SECURITY_MFA_IN_LOGIN_PAGE
@@ -239,19 +238,10 @@ LIMIT_SUPER_PRIV = CONFIG.LIMIT_SUPER_PRIV
ASSET_SIZE = 'small'
# Chat AI
IS_CUSTOM_MODEL = CONFIG.IS_CUSTOM_MODEL
CHAT_AI_ENABLED = CONFIG.CHAT_AI_ENABLED
CHAT_AI_METHOD = CONFIG.CHAT_AI_METHOD
CHAT_AI_EMBED_URL = CONFIG.CHAT_AI_EMBED_URL
CHAT_AI_TYPE = CONFIG.CHAT_AI_TYPE
GPT_BASE_URL = CONFIG.GPT_BASE_URL
GPT_API_KEY = CONFIG.GPT_API_KEY
GPT_PROXY = CONFIG.GPT_PROXY
GPT_MODEL = CONFIG.GPT_MODEL
DEEPSEEK_BASE_URL = CONFIG.DEEPSEEK_BASE_URL
DEEPSEEK_API_KEY = CONFIG.DEEPSEEK_API_KEY
DEEPSEEK_PROXY = CONFIG.DEEPSEEK_PROXY
DEEPSEEK_MODEL = CONFIG.DEEPSEEK_MODEL
CHAT_AI_DEFAULT_PROVIDER = CONFIG.CHAT_AI_DEFAULT_PROVIDER
VIRTUAL_APP_ENABLED = CONFIG.VIRTUAL_APP_ENABLED
@@ -271,3 +261,5 @@ TOOL_USER_ENABLED = CONFIG.TOOL_USER_ENABLED
SUGGESTION_LIMIT = CONFIG.SUGGESTION_LIMIT
MCP_ENABLED = CONFIG.MCP_ENABLED
CHAT_AI_PROVIDERS = CONFIG.CHAT_AI_PROVIDERS

View File

@@ -85,6 +85,7 @@ SPECTACULAR_SETTINGS = {
'jumpserver.views.schema.LabeledChoiceFieldExtension',
'jumpserver.views.schema.BitChoicesFieldExtension',
'jumpserver.views.schema.LabelRelatedFieldExtension',
'jumpserver.views.schema.DateTimeFieldExtension',
],
'SECURITY': [{'Bearer': []}],
}

View File

@@ -37,12 +37,6 @@ api_v1 = resource_api + [
path('prometheus/metrics/', api.PrometheusMetricsApi.as_view()),
path('search/', api.GlobalSearchView.as_view()),
]
if settings.MCP_ENABLED:
api_v1.extend([
path('resources/', api.ResourceTypeListApi.as_view(), name='resource-list'),
path('resources/<str:resource>/', api.ResourceListApi.as_view()),
path('resources/<str:resource>/<str:pk>/', api.ResourceDetailApi.as_view()),
])
app_view_patterns = [
path('auth/', include('authentication.urls.view_urls'), name='auth'),

View File

@@ -104,7 +104,7 @@ class ResourceDownload(TemplateView):
OPENSSH_VERSION=v9.4.0.0
TINKER_VERSION=v0.1.6
VIDEO_PLAYER_VERSION=0.6.0
CLIENT_VERSION=4.1.0
CLIENT_VERSION=4.0.0
"""
def get_meta_json(self):

View File

@@ -1,421 +0,0 @@
import re
from drf_spectacular.openapi import AutoSchema
from drf_spectacular.generators import SchemaGenerator
class CustomSchemaGenerator(SchemaGenerator):
from_mcp = False
def get_schema(self, request=None, public=False):
self.from_mcp = request.query_params.get('mcp') or request.path.endswith('swagger.json')
return super().get_schema(request, public)
class CustomAutoSchema(AutoSchema):
def __init__(self, *args, **kwargs):
self.from_mcp = kwargs.get('from_mcp', False)
super().__init__(*args, **kwargs)
def map_parsers(self):
return ['application/json']
def map_renderers(self, *args, **kwargs):
return ['application/json']
def get_tags(self):
operation_keys = self._tokenize_path()
if len(operation_keys) == 1:
return []
tags = ['_'.join(operation_keys[:2])]
return tags
def get_operation(self, path, *args, **kwargs):
if path.endswith('render-to-json/'):
return None
# if not path.startswith('/api/v1/users'):
# return None
operation = super().get_operation(path, *args, **kwargs)
if not operation:
return operation
if not operation.get('summary', ''):
operation['summary'] = operation.get('operationId')
return operation
def get_operation_id(self):
tokenized_path = self._tokenize_path()
# replace dashes as they can be problematic later in code generation
tokenized_path = [t.replace('-', '_') for t in tokenized_path]
action = ''
if hasattr(self.view, 'action'):
action = self.view.action
if not action:
if self.method == 'GET' and self._is_list_view():
action = 'list'
else:
action = self.method_mapping[self.method.lower()]
if action == "bulk_destroy":
action = "bulk_delete"
if not tokenized_path:
tokenized_path.append('root')
if re.search(r'<drf_format_suffix\w*:\w+>', self.path_regex):
tokenized_path.append('formatted')
return '_'.join(tokenized_path + [action])
def get_filter_parameters(self):
if not self.should_filter():
return []
fields = []
if hasattr(self.view, 'get_filter_backends'):
backends = self.view.get_filter_backends()
elif hasattr(self.view, 'filter_backends'):
backends = self.view.filter_backends
else:
backends = []
for filter_backend in backends:
fields += self.probe_inspectors(
self.filter_inspectors, 'get_filter_parameters', filter_backend()
) or []
return fields
def get_auth(self):
return [{'Bearer': []}]
def get_operation_security(self):
"""
重写操作安全配置,统一使用 Bearer token
"""
return [{'Bearer': []}]
def get_components_security_schemes(self):
"""
重写安全方案定义,避免认证类解析错误
"""
return {
'Bearer': {
'type': 'http',
'scheme': 'bearer',
'bearerFormat': 'JWT',
'description': 'JWT token for API authentication'
}
}
@staticmethod
def exclude_some_paths(path):
# 这里可以对 paths 进行处理
excludes = [
'/report/', '/render-to-json/', '/suggestions/',
'executions', 'automations', 'change-secret-records',
'change-secret-dashboard', '/copy-to-assets/',
'/move-to-assets/', 'dashboard', 'index', 'countries',
'/resources/cache/', 'profile/mfa', 'profile/password',
'profile/permissions', 'prometheus', 'constraints'
]
for p in excludes:
if path.find(p) >= 0:
return True
return False
def exclude_some_app_model(self, path):
parts = path.split('/')
if len(parts) < 5:
return False
apps = []
if self.from_mcp:
apps = [
'ops', 'tickets', 'authentication',
'settings', 'xpack', 'terminal', 'rbac',
'notifications', 'promethues', 'acls'
]
app_name = parts[3]
if app_name in apps:
return True
models = []
model = parts[4]
if self.from_mcp:
models = [
'users', 'user-groups', 'users-groups-relations', 'assets', 'hosts', 'devices', 'databases',
'webs', 'clouds', 'gpts', 'ds', 'customs', 'platforms', 'nodes', 'zones', 'gateways',
'protocol-settings', 'labels', 'virtual-accounts', 'gathered-accounts', 'account-templates',
'account-template-secrets', 'account-backups', 'account-backup-executions',
'change-secret-automations', 'change-secret-executions', 'change-secret-records',
'gather-account-automations', 'gather-account-executions', 'push-account-automations',
'push-account-executions', 'push-account-records', 'check-account-automations',
'check-account-executions', 'account-risks', 'integration-apps', 'asset-permissions',
'asset-permissions-users-relations', 'asset-permissions-user-groups-relations',
'asset-permissions-assets-relations', 'asset-permissions-nodes-relations', 'terminal-status',
'terminals', 'tasks', 'status', 'replay-storages', 'command-storages', 'session-sharing-records',
'endpoints', 'endpoint-rules', 'applets', 'applet-hosts', 'applet-publications',
'applet-host-deployments', 'virtual-apps', 'app-providers', 'virtual-app-publications',
'celery-period-tasks', 'task-executions', 'adhocs', 'playbooks', 'variables', 'ftp-logs',
'login-logs', 'operate-logs', 'password-change-logs', 'job-logs', 'jobs', 'user-sessions',
'service-access-logs', 'chatai-prompts', 'super-connection-tokens', 'flows',
'apply-assets', 'apply-nodes', 'login-acls', 'login-asset-acls', 'command-filter-acls',
'command-groups', 'connect-method-acls', 'system-msg-subscriptions', 'roles', 'role-bindings',
'system-roles', 'system-role-bindings', 'org-roles', 'org-role-bindings', 'content-types',
'labeled-resources', 'account-backup-plans', 'account-check-engines', 'account-secrets',
'change-secret', 'integration-applications', 'push-account', 'directories', 'connection-token',
'groups', 'accounts', 'resource-types', 'favorite-assets', 'activities', 'platform-automation-methods',
]
if model in models:
return True
return False
def is_excluded(self):
if self.exclude_some_paths(self.path):
return True
if self.exclude_some_app_model(self.path):
return True
return False
def get_operation(self, path, *args, **kwargs):
operation = super().get_operation(path, *args, **kwargs)
if not operation:
return operation
operation_id = operation.get('operationId')
if 'bulk' in operation_id:
return None
if not operation.get('summary', ''):
operation['summary'] = operation.get('operationId')
exclude_operations = [
'orgs_orgs_read', 'orgs_orgs_update', 'orgs_orgs_delete',
'orgs_orgs_create', 'orgs_orgs_partial_update',
]
if operation_id in exclude_operations:
return None
return operation
# 添加自定义字段的 OpenAPI 扩展
from drf_spectacular.extensions import OpenApiSerializerFieldExtension
from drf_spectacular.openapi import AutoSchema
from drf_spectacular.plumbing import build_basic_type
from common.serializers.fields import ObjectRelatedField, LabeledChoiceField, BitChoicesField
class ObjectRelatedFieldExtension(OpenApiSerializerFieldExtension):
"""
为 ObjectRelatedField 提供 OpenAPI schema
"""
target_class = ObjectRelatedField
def map_serializer_field(self, auto_schema, direction):
field = self.target
# 获取字段的基本信息
field_type = 'array' if field.many else 'object'
if field_type == 'array':
# 如果是多对多关系
return {
'type': 'array',
'items': self._get_openapi_item_schema(field),
'description': getattr(field, 'help_text', ''),
'title': getattr(field, 'label', ''),
}
else:
# 如果是一对一关系
return {
'type': 'object',
'properties': self._get_openapi_properties_schema(field),
'description': getattr(field, 'help_text', ''),
'title': getattr(field, 'label', ''),
}
def _get_openapi_item_schema(self, field):
"""
获取数组项的 OpenAPI schema
"""
return self._get_openapi_object_schema(field)
def _get_openapi_object_schema(self, field):
"""
获取对象的 OpenAPI schema
"""
properties = {}
# 动态分析 attrs 中的属性类型
for attr in field.attrs:
# 尝试从 queryset 的 model 中获取字段信息
field_type = self._infer_field_type(field, attr)
properties[attr] = {
'type': field_type,
'description': f'{attr} field'
}
return {
'type': 'object',
'properties': properties,
'required': ['id'] if 'id' in field.attrs else []
}
def _infer_field_type(self, field, attr_name):
"""
智能推断字段类型
"""
try:
# 如果有 queryset尝试从 model 中获取字段信息
if hasattr(field, 'queryset') and field.queryset is not None:
model = field.queryset.model
if hasattr(model, '_meta') and hasattr(model._meta, 'fields'):
model_field = model._meta.get_field(attr_name)
if model_field:
return self._map_django_field_type(model_field)
except Exception:
pass
# 如果没有 queryset 或无法获取字段信息,使用启发式规则
return self._heuristic_field_type(attr_name)
def _map_django_field_type(self, model_field):
"""
将 Django 字段类型映射到 OpenAPI 类型
"""
field_type = type(model_field).__name__
# 整数类型
if 'Integer' in field_type or 'BigInteger' in field_type or 'SmallInteger' in field_type:
return 'integer'
# 浮点数类型
elif 'Float' in field_type or 'Decimal' in field_type:
return 'number'
# 布尔类型
elif 'Boolean' in field_type:
return 'boolean'
# 日期时间类型
elif 'DateTime' in field_type or 'Date' in field_type or 'Time' in field_type:
return 'string'
# 文件类型
elif 'File' in field_type or 'Image' in field_type:
return 'string'
# 其他类型默认为字符串
else:
return 'string'
def _heuristic_field_type(self, attr_name):
"""
启发式推断字段类型
"""
# 基于属性名的启发式规则
if attr_name in ['is_active', 'enabled', 'visible'] or attr_name.startswith('is_'):
return 'boolean'
elif attr_name in ['count', 'number', 'size', 'amount']:
return 'integer'
elif attr_name in ['price', 'rate', 'percentage']:
return 'number'
else:
# 默认返回字符串类型
return 'string'
def _get_openapi_properties_schema(self, field):
"""
获取对象属性的 OpenAPI schema
"""
return self._get_openapi_object_schema(field)['properties']
class LabeledChoiceFieldExtension(OpenApiSerializerFieldExtension):
"""
为 LabeledChoiceField 提供 OpenAPI schema
"""
target_class = LabeledChoiceField
def map_serializer_field(self, auto_schema, direction):
field = self.target
if getattr(field, 'many', False):
return {
'type': 'array',
'items': {
'type': 'object',
'properties': {
'value': {'type': 'string'},
'label': {'type': 'string'}
}
},
'description': getattr(field, 'help_text', ''),
'title': getattr(field, 'label', ''),
}
else:
return {
'type': 'object',
'properties': {
'value': {'type': 'string'},
'label': {'type': 'string'}
},
'description': getattr(field, 'help_text', ''),
'title': getattr(field, 'label', ''),
}
class BitChoicesFieldExtension(OpenApiSerializerFieldExtension):
"""
为 BitChoicesField 提供 OpenAPI schema
"""
target_class = BitChoicesField
def map_serializer_field(self, auto_schema, direction):
field = self.target
return {
'type': 'array',
'items': {
'type': 'object',
'properties': {
'value': {'type': 'string'},
'label': {'type': 'string'}
}
},
'description': getattr(field, 'help_text', ''),
'title': getattr(field, 'label', ''),
}
class LabelRelatedFieldExtension(OpenApiSerializerFieldExtension):
"""
为 LabelRelatedField 提供 OpenAPI schema
"""
target_class = 'common.serializers.fields.LabelRelatedField'
def map_serializer_field(self, auto_schema, direction):
field = self.target
# LabelRelatedField 返回一个包含 id, name, value, color 的对象
return {
'type': 'object',
'properties': {
'id': {
'type': 'string',
'description': 'Label ID'
},
'name': {
'type': 'string',
'description': 'Label name'
},
'value': {
'type': 'string',
'description': 'Label value'
},
'color': {
'type': 'string',
'description': 'Label color'
}
},
'required': ['id', 'name', 'value'],
'description': getattr(field, 'help_text', 'Label information'),
'title': getattr(field, 'label', 'Label'),
}

View File

@@ -0,0 +1,2 @@
from .extension import *
from .schema import *

View File

@@ -0,0 +1,263 @@
# 添加自定义字段的 OpenAPI 扩展
from drf_spectacular.extensions import OpenApiSerializerFieldExtension
from drf_spectacular.openapi import AutoSchema
from drf_spectacular.plumbing import build_basic_type
from rest_framework import serializers
from common.serializers.fields import ObjectRelatedField, LabeledChoiceField, BitChoicesField
__all__ = [
'ObjectRelatedFieldExtension', 'LabeledChoiceFieldExtension',
'BitChoicesFieldExtension', 'LabelRelatedFieldExtension',
'DateTimeFieldExtension'
]
class ObjectRelatedFieldExtension(OpenApiSerializerFieldExtension):
"""
为 ObjectRelatedField 提供 OpenAPI schema
"""
target_class = ObjectRelatedField
def map_serializer_field(self, auto_schema, direction):
field = self.target
# 获取字段的基本信息
field_type = 'array' if field.many else 'object'
if field_type == 'array':
# 如果是多对多关系
return {
'type': 'array',
'items': self._get_openapi_item_schema(field),
'description': getattr(field, 'help_text', ''),
'title': getattr(field, 'label', ''),
}
else:
# 如果是一对一关系
return {
'type': 'object',
'properties': self._get_openapi_properties_schema(field),
'description': getattr(field, 'help_text', ''),
'title': getattr(field, 'label', ''),
}
def _get_openapi_item_schema(self, field):
"""
获取数组项的 OpenAPI schema
"""
return self._get_openapi_object_schema(field)
def _get_openapi_object_schema(self, field):
"""
获取对象的 OpenAPI schema
"""
properties = {}
# 动态分析 attrs 中的属性类型
for attr in field.attrs:
# 尝试从 queryset 的 model 中获取字段信息
field_type = self._infer_field_type(field, attr)
properties[attr] = {
'type': field_type,
'description': f'{attr} field'
}
return {
'type': 'object',
'properties': properties,
'required': ['id'] if 'id' in field.attrs else []
}
def _infer_field_type(self, field, attr_name):
"""
智能推断字段类型
"""
try:
# 如果有 queryset尝试从 model 中获取字段信息
if hasattr(field, 'queryset') and field.queryset is not None:
model = field.queryset.model
if hasattr(model, '_meta') and hasattr(model._meta, 'fields'):
model_field = model._meta.get_field(attr_name)
if model_field:
return self._map_django_field_type(model_field)
except Exception:
pass
# 如果没有 queryset 或无法获取字段信息,使用启发式规则
return self._heuristic_field_type(attr_name)
def _map_django_field_type(self, model_field):
"""
将 Django 字段类型映射到 OpenAPI 类型
"""
field_type = type(model_field).__name__
# 整数类型
if 'Integer' in field_type or 'BigInteger' in field_type or 'SmallInteger' in field_type or 'AutoField' in field_type:
return 'integer'
# 浮点数类型
elif 'Float' in field_type or 'Decimal' in field_type:
return 'number'
# 布尔类型
elif 'Boolean' in field_type:
return 'boolean'
# 日期时间类型
elif 'DateTime' in field_type or 'Date' in field_type or 'Time' in field_type:
return 'string'
# 文件类型
elif 'File' in field_type or 'Image' in field_type:
return 'string'
# 其他类型默认为字符串
else:
return 'string'
def _heuristic_field_type(self, attr_name):
"""
启发式推断字段类型
"""
# 基于属性名的启发式规则
if attr_name in ['is_active', 'enabled', 'visible'] or attr_name.startswith('is_'):
return 'boolean'
elif attr_name in ['count', 'number', 'size', 'amount']:
return 'integer'
elif attr_name in ['price', 'rate', 'percentage']:
return 'number'
else:
# 默认返回字符串类型
return 'string'
def _get_openapi_properties_schema(self, field):
"""
获取对象属性的 OpenAPI schema
"""
return self._get_openapi_object_schema(field)['properties']
class LabeledChoiceFieldExtension(OpenApiSerializerFieldExtension):
"""
为 LabeledChoiceField 提供 OpenAPI schema
"""
target_class = LabeledChoiceField
def map_serializer_field(self, auto_schema, direction):
field = self.target
if getattr(field, 'many', False):
return {
'type': 'array',
'items': {
'type': 'object',
'properties': {
'value': {'type': 'string'},
'label': {'type': 'string'}
}
},
'description': getattr(field, 'help_text', ''),
'title': getattr(field, 'label', ''),
}
else:
return {
'type': 'object',
'properties': {
'value': {'type': 'string'},
'label': {'type': 'string'}
},
'description': getattr(field, 'help_text', ''),
'title': getattr(field, 'label', ''),
}
class BitChoicesFieldExtension(OpenApiSerializerFieldExtension):
"""
为 BitChoicesField 提供 OpenAPI schema
"""
target_class = BitChoicesField
def map_serializer_field(self, auto_schema, direction):
field = self.target
return {
'type': 'array',
'items': {
'type': 'object',
'properties': {
'value': {'type': 'string'},
'label': {'type': 'string'}
}
},
'description': getattr(field, 'help_text', ''),
'title': getattr(field, 'label', ''),
}
class LabelRelatedFieldExtension(OpenApiSerializerFieldExtension):
"""
为 LabelRelatedField 提供 OpenAPI schema
"""
target_class = 'common.serializers.fields.LabelRelatedField'
def map_serializer_field(self, auto_schema, direction):
field = self.target
# LabelRelatedField 返回一个包含 id, name, value, color 的对象
return {
'type': 'object',
'properties': {
'id': {
'type': 'string',
'description': 'Label ID'
},
'name': {
'type': 'string',
'description': 'Label name'
},
'value': {
'type': 'string',
'description': 'Label value'
},
'color': {
'type': 'string',
'description': 'Label color'
}
},
'required': ['id', 'name', 'value'],
'description': getattr(field, 'help_text', 'Label information'),
'title': getattr(field, 'label', 'Label'),
}
class DateTimeFieldExtension(OpenApiSerializerFieldExtension):
"""
为 DateTimeField 提供自定义 OpenAPI schema
修正 datetime 字段格式,使其符合实际返回格式 '%Y/%m/%d %H:%M:%S %z'
而不是标准的 ISO 8601 格式 (date-time)
"""
target_class = serializers.DateTimeField
def map_serializer_field(self, auto_schema, direction):
field = self.target
# 获取字段的描述信息,确保始终是字符串类型
help_text = getattr(field, 'help_text', None) or ''
description = help_text if isinstance(help_text, str) else ''
# 添加格式说明
format_desc = 'Format: YYYY/MM/DD HH:MM:SS +TZ (e.g., 2023/10/01 12:00:00 +0800)'
if description:
description = f'{description} {format_desc}'
else:
description = format_desc
# 返回字符串类型,不包含 format: date-time
# 因为实际返回格式是 '%Y/%m/%d %H:%M:%S %z',不是标准的 ISO 8601
schema = {
'type': 'string',
'description': description,
'title': getattr(field, 'label', '') or '',
'example': '2023/10/01 12:00:00 +0800',
}
return schema

View File

@@ -0,0 +1,206 @@
import re
from drf_spectacular.openapi import AutoSchema
from drf_spectacular.generators import SchemaGenerator
__all__ = [
'CustomSchemaGenerator', 'CustomAutoSchema'
]
class CustomSchemaGenerator(SchemaGenerator):
from_mcp = False
def get_schema(self, request=None, public=False):
self.from_mcp = request.query_params.get('mcp') or request.path.endswith('swagger.json')
return super().get_schema(request, public)
class CustomAutoSchema(AutoSchema):
def __init__(self, *args, **kwargs):
self.from_mcp = True
super().__init__(*args, **kwargs)
def map_parsers(self):
return ['application/json']
def map_renderers(self, *args, **kwargs):
return ['application/json']
def get_tags(self):
operation_keys = self._tokenize_path()
if len(operation_keys) == 1:
return []
tags = ['_'.join(operation_keys[:2])]
return tags
def get_operation_id(self):
tokenized_path = self._tokenize_path()
# replace dashes as they can be problematic later in code generation
tokenized_path = [t.replace('-', '_') for t in tokenized_path]
action = ''
if hasattr(self.view, 'action'):
action = self.view.action
if not action:
if self.method == 'GET' and self._is_list_view():
action = 'list'
else:
action = self.method_mapping[self.method.lower()]
if action == "bulk_destroy":
action = "bulk_delete"
if not tokenized_path:
tokenized_path.append('root')
if re.search(r'<drf_format_suffix\w*:\w+>', self.path_regex):
tokenized_path.append('formatted')
return '_'.join(tokenized_path + [action])
def get_filter_parameters(self):
if not self.should_filter():
return []
fields = []
if hasattr(self.view, 'get_filter_backends'):
backends = self.view.get_filter_backends()
elif hasattr(self.view, 'filter_backends'):
backends = self.view.filter_backends
else:
backends = []
for filter_backend in backends:
fields += self.probe_inspectors(
self.filter_inspectors, 'get_filter_parameters', filter_backend()
) or []
return fields
def get_auth(self):
return [{'Bearer': []}]
def get_operation_security(self):
"""
重写操作安全配置,统一使用 Bearer token
"""
return [{'Bearer': []}]
def get_components_security_schemes(self):
"""
重写安全方案定义,避免认证类解析错误
"""
return {
'Bearer': {
'type': 'http',
'scheme': 'bearer',
'bearerFormat': 'JWT',
'description': 'JWT token for API authentication'
}
}
@staticmethod
def exclude_some_paths(path):
# 这里可以对 paths 进行处理
excludes = [
'/report/', '/render-to-json/', '/suggestions/',
'executions', 'automations', 'change-secret-records',
'change-secret-dashboard', '/copy-to-assets/',
'/move-to-assets/', 'dashboard', 'index', 'countries',
'/resources/cache/', 'profile/mfa', 'profile/password',
'profile/permissions', 'prometheus', 'constraints',
'/api/swagger.json', '/api/swagger.yaml',
]
for p in excludes:
if path.find(p) >= 0:
return True
return False
def exclude_some_models(self, model):
models = []
if self.from_mcp:
models = [
'users', 'user-groups',
'assets', 'hosts', 'devices', 'databases',
'webs', 'clouds', 'ds', 'platforms',
'nodes', 'zones', 'labels',
'accounts', 'account-templates',
'asset-permissions',
]
if models and model in models:
return False
return True
def exclude_some_apps(self, app):
apps = []
if self.from_mcp:
apps = [
'users', 'assets', 'accounts',
'perms', 'labels',
]
if apps and app in apps:
return False
return True
def exclude_some_app_model(self, path):
parts = path.split('/')
if len(parts) < 5 :
return True
if len(parts) == 7 and parts[5] != "{id}":
return True
if len(parts) > 7:
return True
app_name = parts[3]
if self.exclude_some_apps(app_name):
return True
if self.exclude_some_models(parts[4]):
return True
return False
def is_excluded(self):
if self.exclude_some_paths(self.path):
return True
if self.exclude_some_app_model(self.path):
return True
return False
def exclude_some_operations(self, operation_id):
exclude_operations = [
'orgs_orgs_read', 'orgs_orgs_update', 'orgs_orgs_delete',
'orgs_orgs_create', 'orgs_orgs_partial_update',
]
if operation_id in exclude_operations:
return True
if 'bulk' in operation_id:
return True
if 'destroy' in operation_id:
return True
if 'update' in operation_id and 'partial' not in operation_id:
return True
return False
def get_operation(self, path, *args, **kwargs):
operation = super().get_operation(path, *args, **kwargs)
if not operation:
return operation
operation_id = operation.get('operationId')
if self.exclude_some_operations(operation_id):
return None
if not operation.get('summary', ''):
operation['summary'] = operation.get('operationId')
# if self.is_excluded():
# return None
return operation

View File

@@ -37,11 +37,11 @@ class SchemeMixin:
}
return Response(schema)
@method_decorator(cache_page(60 * 5,), name="dispatch")
# @method_decorator(cache_page(60 * 5,), name="dispatch")
class JsonApi(SchemeMixin, SpectacularJSONAPIView):
pass
@method_decorator(cache_page(60 * 5,), name="dispatch")
# @method_decorator(cache_page(60 * 5,), name="dispatch")
class YamlApi(SchemeMixin, SpectacularYAMLAPIView):
pass

View File

@@ -1,104 +1,10 @@
import httpx
import openai
from django.conf import settings
from django.utils.translation import gettext_lazy as _
from rest_framework import status
from rest_framework.generics import GenericAPIView
from rest_framework.response import Response
from common.api import JMSModelViewSet
from common.permissions import IsValidUser, OnlySuperUser
from .. import serializers
from ..const import ChatAITypeChoices
from ..models import ChatPrompt
from ..prompt import DefaultChatPrompt
class ChatAITestingAPI(GenericAPIView):
serializer_class = serializers.ChatAISettingSerializer
rbac_perms = {
'POST': 'settings.change_chatai'
}
def get_config(self, request):
serializer = self.serializer_class(data=request.data)
serializer.is_valid(raise_exception=True)
data = self.serializer_class().data
data.update(serializer.validated_data)
for k, v in data.items():
if v:
continue
# 页面没有传递值, 从 settings 中获取
data[k] = getattr(settings, k, None)
return data
def post(self, request):
config = self.get_config(request)
chat_ai_enabled = config['CHAT_AI_ENABLED']
if not chat_ai_enabled:
return Response(
status=status.HTTP_400_BAD_REQUEST,
data={'msg': _('Chat AI is not enabled')}
)
tp = config['CHAT_AI_TYPE']
is_custom_model = config['IS_CUSTOM_MODEL']
if tp == ChatAITypeChoices.gpt:
url = config['GPT_BASE_URL']
api_key = config['GPT_API_KEY']
proxy = config['GPT_PROXY']
model = config['GPT_MODEL']
custom_model = config['CUSTOM_GPT_MODEL']
else:
url = config['DEEPSEEK_BASE_URL']
api_key = config['DEEPSEEK_API_KEY']
proxy = config['DEEPSEEK_PROXY']
model = config['DEEPSEEK_MODEL']
custom_model = config['CUSTOM_DEEPSEEK_MODEL']
model = custom_model or model \
if is_custom_model else model
kwargs = {
'base_url': url or None,
'api_key': api_key,
}
try:
if proxy:
kwargs['http_client'] = httpx.Client(
proxies=proxy,
transport=httpx.HTTPTransport(local_address='0.0.0.0')
)
client = openai.OpenAI(**kwargs)
ok = False
error = ''
client.chat.completions.create(
messages=[
{
"role": "user",
"content": "Say this is a test",
}
],
model=model,
)
ok = True
except openai.APIConnectionError as e:
error = str(e.__cause__) # an underlying Exception, likely raised within httpx.
except openai.APIStatusError as e:
error = str(e.message)
except Exception as e:
ok, error = False, str(e)
if ok:
_status, msg = status.HTTP_200_OK, _('Test success')
else:
_status, msg = status.HTTP_400_BAD_REQUEST, error
return Response(status=_status, data={'msg': msg})
class ChatPromptViewSet(JMSModelViewSet):
serializer_classes = {
'default': serializers.ChatPromptSerializer,

View File

@@ -154,7 +154,10 @@ class SettingsApi(generics.RetrieveUpdateAPIView):
def parse_serializer_data(self, serializer):
data = []
fields = self.get_fields()
encrypted_items = [name for name, field in fields.items() if field.write_only]
encrypted_items = [
name for name, field in fields.items()
if field.write_only or getattr(field, 'encrypted', False)
]
category = self.request.query_params.get('category', '')
for name, value in serializer.validated_data.items():
encrypted = name in encrypted_items
@@ -222,4 +225,4 @@ class ClientVersionView(APIView):
permission_classes = (AllowAny,)
def get(self, request, *args, **kwargs):
return Response(['4.0.0', '4.1.0'], status=status.HTTP_200_OK)
return Response(['4.0.0'], status=status.HTTP_200_OK)

View File

@@ -14,30 +14,5 @@ class ChatAIMethodChoices(TextChoices):
class ChatAITypeChoices(TextChoices):
gpt = 'gpt', 'GPT'
deep_seek = 'deep-seek', 'DeepSeek'
class GPTModelChoices(TextChoices):
# 🚀 Latest flagship dialogue model
GPT_5_2 = 'gpt-5.2', 'gpt-5.2'
GPT_5_2_PRO = 'gpt-5.2-pro', 'gpt-5.2-pro'
GPT_5_1 = 'gpt-5.1', 'gpt-5.1'
GPT_5 = 'gpt-5', 'gpt-5'
# 💡 Lightweight & Cost-Friendly Version
GPT_5_MINI = 'gpt-5-mini', 'gpt-5-mini'
GPT_5_NANO = 'gpt-5-nano', 'gpt-5-nano'
# 🧠 GPT-4 series of dialogues (still supports chat tasks)
GPT_4O = 'gpt-4o', 'gpt-4o'
GPT_4O_MINI = 'gpt-4o-mini', 'gpt-4o-mini'
GPT_4_1 = 'gpt-4.1', 'gpt-4.1'
GPT_4_1_MINI = 'gpt-4.1-mini', 'gpt-4.1-mini'
GPT_4_1_NANO = 'gpt-4.1-nano', 'gpt-4.1-nano'
class DeepSeekModelChoices(TextChoices):
deepseek_chat = 'deepseek-chat', 'DeepSeek-V3'
deepseek_reasoner = 'deepseek-reasoner', 'DeepSeek-R1'
openai = 'openai', 'Openai'
ollama = 'ollama', 'Ollama'

View File

@@ -1,6 +1,7 @@
import json
import os
import shutil
from typing import Any, Dict, List
from django.conf import settings
from django.core.files.base import ContentFile
@@ -14,7 +15,6 @@ from rest_framework.utils.encoders import JSONEncoder
from common.db.models import JMSBaseModel
from common.db.utils import Encryptor
from common.utils import get_logger
from .const import ChatAITypeChoices
from .signals import setting_changed
logger = get_logger(__name__)
@@ -196,24 +196,25 @@ class ChatPrompt(JMSBaseModel):
return self.name
def get_chatai_data():
data = {
'url': settings.GPT_BASE_URL,
'api_key': settings.GPT_API_KEY,
'proxy': settings.GPT_PROXY,
'model': settings.GPT_MODEL,
}
custom_model = settings.CUSTOM_GPT_MODEL
if settings.CHAT_AI_TYPE != ChatAITypeChoices.gpt:
data['url'] = settings.DEEPSEEK_BASE_URL
data['api_key'] = settings.DEEPSEEK_API_KEY
data['proxy'] = settings.DEEPSEEK_PROXY
data['model'] = settings.DEEPSEEK_MODEL
custom_model = settings.CUSTOM_DEEPSEEK_MODEL
def get_chatai_data() -> Dict[str, Any]:
raw_providers = settings.CHAT_AI_PROVIDERS
providers: List[dict] = [p for p in raw_providers if isinstance(p, dict)]
data['model'] = custom_model or data['model'] \
if settings.IS_CUSTOM_MODEL else data['model']
return data
if not providers:
return {}
selected = next(
(p for p in providers if p.get('is_assistant')),
providers[0],
)
return {
'url': selected.get('base_url'),
'api_key': selected.get('api_key'),
'proxy': selected.get('proxy'),
'model': selected.get('model'),
'name': selected.get('name'),
}
def init_sqlite_db():

View File

@@ -10,11 +10,12 @@ from common.utils import date_expired_default
__all__ = [
'AnnouncementSettingSerializer', 'OpsSettingSerializer', 'VaultSettingSerializer',
'HashicorpKVSerializer', 'AzureKVSerializer', 'TicketSettingSerializer',
'ChatAISettingSerializer', 'VirtualAppSerializer', 'AmazonSMSerializer',
'ChatAIProviderSerializer', 'ChatAISettingSerializer',
'VirtualAppSerializer', 'AmazonSMSerializer',
]
from settings.const import (
ChatAITypeChoices, GPTModelChoices, DeepSeekModelChoices, ChatAIMethodChoices
ChatAITypeChoices, ChatAIMethodChoices
)
@@ -120,6 +121,29 @@ class AmazonSMSerializer(serializers.Serializer):
)
class ChatAIProviderListSerializer(serializers.ListSerializer):
# 标记整个列表需要加密存储,避免明文保存 API Key
encrypted = True
class ChatAIProviderSerializer(serializers.Serializer):
type = serializers.ChoiceField(
default=ChatAITypeChoices.openai, choices=ChatAITypeChoices.choices,
label=_("Types"), required=False,
)
base_url = serializers.CharField(
allow_blank=True, required=False, label=_('Base URL'),
help_text=_('The base URL of the Chat service.')
)
api_key = EncryptedField(
allow_blank=True, required=False, label=_('API Key'),
)
proxy = serializers.CharField(
allow_blank=True, required=False, label=_('Proxy'),
help_text=_('The proxy server address of the GPT service. For example: http://ip:port')
)
class ChatAISettingSerializer(serializers.Serializer):
PREFIX_TITLE = _('Chat AI')
@@ -130,56 +154,14 @@ class ChatAISettingSerializer(serializers.Serializer):
default=ChatAIMethodChoices.api, choices=ChatAIMethodChoices.choices,
label=_("Method"), required=False,
)
CHAT_AI_PROVIDERS = ChatAIProviderListSerializer(
child=ChatAIProviderSerializer(),
allow_empty=True, required=False, default=list, label=_('Providers')
)
CHAT_AI_EMBED_URL = serializers.CharField(
allow_blank=True, required=False, label=_('Base URL'),
help_text=_('The base URL of the Chat service.')
)
CHAT_AI_TYPE = serializers.ChoiceField(
default=ChatAITypeChoices.gpt, choices=ChatAITypeChoices.choices,
label=_("Types"), required=False,
)
GPT_BASE_URL = serializers.CharField(
allow_blank=True, required=False, label=_('Base URL'),
help_text=_('The base URL of the Chat service.')
)
GPT_API_KEY = EncryptedField(
allow_blank=True, required=False, label=_('API Key'),
)
GPT_PROXY = serializers.CharField(
allow_blank=True, required=False, label=_('Proxy'),
help_text=_('The proxy server address of the GPT service. For example: http://ip:port')
)
GPT_MODEL = serializers.ChoiceField(
default=GPTModelChoices.GPT_4_1_MINI, choices=GPTModelChoices.choices,
label=_("GPT Model"), required=False,
)
DEEPSEEK_BASE_URL = serializers.CharField(
allow_blank=True, required=False, label=_('Base URL'),
help_text=_('The base URL of the Chat service.')
)
DEEPSEEK_API_KEY = EncryptedField(
allow_blank=True, required=False, label=_('API Key'),
)
DEEPSEEK_PROXY = serializers.CharField(
allow_blank=True, required=False, label=_('Proxy'),
help_text=_('The proxy server address of the GPT service. For example: http://ip:port')
)
DEEPSEEK_MODEL = serializers.ChoiceField(
default=DeepSeekModelChoices.deepseek_chat, choices=DeepSeekModelChoices.choices,
label=_("DeepSeek Model"), required=False,
)
IS_CUSTOM_MODEL = serializers.BooleanField(
required=False, default=False, label=_("Custom Model"),
help_text=_("Whether to use a custom model")
)
CUSTOM_GPT_MODEL = serializers.CharField(
max_length=256, allow_blank=True,
required=False, label=_('Custom gpt model'),
)
CUSTOM_DEEPSEEK_MODEL = serializers.CharField(
max_length=256, allow_blank=True,
required=False, label=_('Custom DeepSeek model'),
)
class TicketSettingSerializer(serializers.Serializer):

View File

@@ -21,7 +21,6 @@ class PrivateSettingSerializer(PublicSettingSerializer):
TICKET_AUTHORIZE_DEFAULT_TIME_UNIT = serializers.CharField()
AUTH_LDAP_SYNC_ORG_IDS = serializers.ListField()
SECURITY_MAX_IDLE_TIME = serializers.IntegerField()
SECURITY_ACCOUNT_SECRET_READ = serializers.BooleanField()
SECURITY_VIEW_AUTH_NEED_MFA = serializers.BooleanField()
SECURITY_MFA_AUTH = serializers.IntegerField()
SECURITY_MFA_VERIFY_TTL = serializers.IntegerField()
@@ -74,7 +73,6 @@ class PrivateSettingSerializer(PublicSettingSerializer):
CHAT_AI_ENABLED = serializers.BooleanField()
CHAT_AI_METHOD = serializers.CharField()
CHAT_AI_EMBED_URL = serializers.CharField()
CHAT_AI_TYPE = serializers.CharField()
GPT_MODEL = serializers.CharField()
FILE_UPLOAD_SIZE_LIMIT_MB = serializers.IntegerField()
FTP_FILE_MAX_STORE = serializers.IntegerField()

View File

@@ -21,7 +21,6 @@ urlpatterns = [
path('sms/<str:backend>/testing/', api.SMSTestingAPI.as_view(), name='sms-testing'),
path('sms/backend/', api.SMSBackendAPI.as_view(), name='sms-backend'),
path('vault/<str:backend>/testing/', api.VaultTestingAPI.as_view(), name='vault-testing'),
path('chatai/testing/', api.ChatAITestingAPI.as_view(), name='chatai-testing'),
path('vault/sync/', api.VaultSyncDataAPI.as_view(), name='vault-sync'),
path('security/block-ip/', api.BlockIPSecurityAPI.as_view(), name='block-ip'),
path('security/unlock-ip/', api.UnlockIPSecurityAPI.as_view(), name='unlock-ip'),

View File

@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
#
from .applet import *
from .chat import *
from .component import *
from .session import *
from .virtualapp import *

View File

@@ -0,0 +1 @@
from .chat import *

View File

@@ -0,0 +1,15 @@
from common.api import JMSBulkModelViewSet
from terminal import serializers
from terminal.filters import ChatFilter
from terminal.models import Chat
__all__ = ['ChatViewSet']
class ChatViewSet(JMSBulkModelViewSet):
queryset = Chat.objects.all()
serializer_class = serializers.ChatSerializer
filterset_class = ChatFilter
search_fields = ['title']
ordering_fields = ['date_updated']
ordering = ['-date_updated']

View File

@@ -2,7 +2,7 @@ from django.db.models import QuerySet
from django_filters import rest_framework as filters
from orgs.utils import filter_org_queryset
from terminal.models import Command, CommandStorage, Session
from terminal.models import Command, CommandStorage, Session, Chat
class CommandFilter(filters.FilterSet):
@@ -79,7 +79,34 @@ class CommandStorageFilter(filters.FilterSet):
model = CommandStorage
fields = ['real', 'name', 'type', 'is_default']
def filter_real(self, queryset, name, value):
@staticmethod
def filter_real(queryset, name, value):
if value:
queryset = queryset.exclude(name='null')
return queryset
class ChatFilter(filters.FilterSet):
ids = filters.BooleanFilter(method='filter_ids')
folder_ids = filters.BooleanFilter(method='filter_folder_ids')
class Meta:
model = Chat
fields = [
'title', 'user_id', 'pinned', 'folder_id',
'archived', 'socket_id', 'share_id'
]
@staticmethod
def filter_ids(queryset, name, value):
ids = value.split(',')
queryset = queryset.filter(id__in=ids)
return queryset
@staticmethod
def filter_folder_ids(queryset, name, value):
ids = value.split(',')
queryset = queryset.filter(folder_id__in=ids)
return queryset

View File

@@ -0,0 +1,38 @@
# Generated by Django 4.1.13 on 2025-09-30 06:57
from django.db import migrations, models
import uuid
class Migration(migrations.Migration):
dependencies = [
('terminal', '0010_alter_command_risk_level_alter_session_login_from_and_more'),
]
operations = [
migrations.CreateModel(
name='Chat',
fields=[
('created_by', models.CharField(blank=True, max_length=128, null=True, verbose_name='Created by')),
('updated_by', models.CharField(blank=True, max_length=128, null=True, verbose_name='Updated by')),
('date_created', models.DateTimeField(auto_now_add=True, null=True, verbose_name='Date created')),
('date_updated', models.DateTimeField(auto_now=True, verbose_name='Date updated')),
('comment', models.TextField(blank=True, default='', verbose_name='Comment')),
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
('title', models.CharField(max_length=256, verbose_name='Title')),
('chat', models.JSONField(default=dict, verbose_name='Chat')),
('meta', models.JSONField(default=dict, verbose_name='Meta')),
('pinned', models.BooleanField(default=False, verbose_name='Pinned')),
('archived', models.BooleanField(default=False, verbose_name='Archived')),
('share_id', models.CharField(blank=True, default='', max_length=36)),
('folder_id', models.CharField(blank=True, default='', max_length=36)),
('socket_id', models.CharField(blank=True, default='', max_length=36)),
('user_id', models.CharField(blank=True, db_index=True, default='', max_length=36)),
('session_info', models.JSONField(default=dict, verbose_name='Session Info')),
],
options={
'verbose_name': 'Chat',
},
),
]

View File

@@ -1,4 +1,5 @@
from .session import *
from .component import *
from .applet import *
from .chat import *
from .component import *
from .session import *
from .virtualapp import *

View File

@@ -0,0 +1 @@
from .chat import *

View File

@@ -0,0 +1,30 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
from common.db.models import JMSBaseModel
from common.utils import get_logger
logger = get_logger(__name__)
__all__ = ['Chat']
class Chat(JMSBaseModel):
# id == session_id # 36 chars
title = models.CharField(max_length=256, verbose_name=_('Title'))
chat = models.JSONField(default=dict, verbose_name=_('Chat'))
meta = models.JSONField(default=dict, verbose_name=_('Meta'))
pinned = models.BooleanField(default=False, verbose_name=_('Pinned'))
archived = models.BooleanField(default=False, verbose_name=_('Archived'))
share_id = models.CharField(blank=True, default='', max_length=36)
folder_id = models.CharField(blank=True, default='', max_length=36)
socket_id = models.CharField(blank=True, default='', max_length=36)
user_id = models.CharField(blank=True, default='', max_length=36, db_index=True)
session_info = models.JSONField(default=dict, verbose_name=_('Session Info'))
class Meta:
verbose_name = _('Chat')
def __str__(self):
return self.title

View File

@@ -123,11 +123,11 @@ class Terminal(StorageMixin, TerminalStatusMixin, JMSBaseModel):
def get_chat_ai_setting():
data = get_chatai_data()
return {
'GPT_BASE_URL': data['url'],
'GPT_API_KEY': data['api_key'],
'GPT_PROXY': data['proxy'],
'GPT_MODEL': data['model'],
'CHAT_AI_TYPE': settings.CHAT_AI_TYPE,
'GPT_BASE_URL': data.get('url'),
'GPT_API_KEY': data.get('api_key'),
'GPT_PROXY': data.get('proxy'),
'GPT_MODEL': data.get('model'),
'CHAT_AI_PROVIDERS': settings.CHAT_AI_PROVIDERS,
}
@staticmethod

View File

@@ -2,8 +2,10 @@
#
from .applet import *
from .applet_host import *
from .chat import *
from .command import *
from .endpoint import *
from .loki import *
from .session import *
from .sharing import *
from .storage import *
@@ -11,4 +13,3 @@ from .task import *
from .terminal import *
from .virtualapp import *
from .virtualapp_provider import *
from .loki import *

View File

@@ -0,0 +1,28 @@
from rest_framework import serializers
from common.serializers import CommonBulkModelSerializer
from terminal.models import Chat
__all__ = ['ChatSerializer']
class ChatSerializer(CommonBulkModelSerializer):
created_at = serializers.SerializerMethodField()
updated_at = serializers.SerializerMethodField()
class Meta:
model = Chat
fields_mini = ['id', 'title', 'created_at', 'updated_at']
fields = fields_mini + [
'chat', 'meta', 'pinned', 'archived',
'share_id', 'folder_id',
'user_id', 'session_info'
]
@staticmethod
def get_created_at(obj):
return int(obj.date_created.timestamp())
@staticmethod
def get_updated_at(obj):
return int(obj.date_updated.timestamp())

View File

@@ -32,6 +32,7 @@ router.register(r'virtual-apps', api.VirtualAppViewSet, 'virtual-app')
router.register(r'app-providers', api.AppProviderViewSet, 'app-provider')
router.register(r'app-providers/((?P<provider>[^/.]+)/)?apps', api.AppProviderAppViewSet, 'app-provider-app')
router.register(r'virtual-app-publications', api.VirtualAppPublicationViewSet, 'virtual-app-publication')
router.register(r'chats', api.ChatViewSet, 'chat')
urlpatterns = [
path('my-sessions/', api.MySessionAPIView.as_view(), name='my-session'),

View File

@@ -199,11 +199,19 @@ class UserChangePasswordApi(UserQuerysetMixin, generics.UpdateAPIView):
class UserUnblockPKApi(UserQuerysetMixin, generics.UpdateAPIView):
serializer_class = serializers.UserSerializer
def get_object(self):
pk = self.kwargs.get('pk')
if is_uuid(pk):
return super().get_object()
else:
return self.get_queryset().filter(username=pk).first()
def perform_update(self, serializer):
user = self.get_object()
username = user.username if user else ''
LoginBlockUtil.unblock_user(username)
MFABlockUtils.unblock_user(username)
if not user:
return Response({"error": _("User not found")}, status=404)
user.unblock_login()
class UserResetMFAApi(UserQuerysetMixin, generics.RetrieveAPIView):

View File

@@ -274,8 +274,8 @@ class User(
LoginBlockUtil.unblock_user(self.username)
MFABlockUtils.unblock_user(self.username)
@lazyproperty
def login_blocked(self):
@property
def is_login_blocked(self):
from users.utils import LoginBlockUtil, MFABlockUtils
if LoginBlockUtil.is_user_block(self.username):
@@ -284,6 +284,13 @@ class User(
return True
return False
@classmethod
def block_login(cls, username):
from users.utils import LoginBlockUtil, MFABlockUtils
LoginBlockUtil.block_user(username)
MFABlockUtils.block_user(username)
def delete(self, using=None, keep_parents=False):
if self.pk == 1 or self.username == "admin":
raise PermissionDenied(_("Can not delete admin user"))

View File

@@ -123,7 +123,7 @@ class UserSerializer(
mfa_force_enabled = serializers.BooleanField(
read_only=True, label=_("MFA force enabled")
)
login_blocked = serializers.BooleanField(read_only=True, label=_("Login blocked"))
is_login_blocked = serializers.BooleanField(read_only=True, label=_("Login blocked"))
is_expired = serializers.BooleanField(read_only=True, label=_("Is expired"))
is_valid = serializers.BooleanField(read_only=True, label=_("Is valid"))
is_otp_secret_key_bound = serializers.BooleanField(
@@ -193,6 +193,7 @@ class UserSerializer(
"is_valid", "is_expired", "is_active", # 布尔字段
"is_otp_secret_key_bound", "can_public_key_auth",
"mfa_enabled", "need_update_password", "is_face_code_set",
"is_login_blocked",
]
# 包含不太常用的字段,可以没有
fields_verbose = (
@@ -211,7 +212,7 @@ class UserSerializer(
# 多对多字段
fields_m2m = ["groups", "system_roles", "org_roles", "orgs_roles", "labels"]
# 在serializer 上定义的字段
fields_custom = ["login_blocked", "password_strategy"]
fields_custom = ["is_login_blocked", "password_strategy"]
fields = fields_verbose + fields_fk + fields_m2m + fields_custom
fields_unexport = ["avatar_url", "is_service_account"]

View File

@@ -28,6 +28,6 @@ urlpatterns = [
path('users/<uuid:pk>/password/', api.UserChangePasswordApi.as_view(), name='change-user-password'),
path('users/<uuid:pk>/password/reset/', api.UserResetPasswordApi.as_view(), name='user-reset-password'),
path('users/<uuid:pk>/pubkey/reset/', api.UserResetPKApi.as_view(), name='user-public-key-reset'),
path('users/<uuid:pk>/unblock/', api.UserUnblockPKApi.as_view(), name='user-unblock'),
path('users/<str:pk>/unblock/', api.UserUnblockPKApi.as_view(), name='user-unblock'),
]
urlpatterns += router.urls

View File

@@ -186,6 +186,13 @@ class BlockUtilBase:
def is_block(self):
return bool(cache.get(self.block_key))
@classmethod
def block_user(cls, username):
username = username.lower()
block_key = cls.BLOCK_KEY_TMPL.format(username)
key_ttl = int(settings.SECURITY_LOGIN_LIMIT_TIME) * 60
cache.set(block_key, True, key_ttl)
@classmethod
def get_blocked_usernames(cls):
key = cls.BLOCK_KEY_TMPL.format('*')