From 70912cdb95a88316b58a332dffb5ed036c5130bd Mon Sep 17 00:00:00 2001 From: wangruidong <940853815@qq.com> Date: Thu, 28 Nov 2024 10:28:33 +0800 Subject: [PATCH 1/3] fix: Expand resource_type filter to include raw type --- apps/audits/filters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/audits/filters.py b/apps/audits/filters.py index b82eadad3..ac84aaf7f 100644 --- a/apps/audits/filters.py +++ b/apps/audits/filters.py @@ -63,7 +63,7 @@ class OperateLogFilterSet(BaseFilterSet): with translation.override(current_lang): mapper = {str(m._meta.verbose_name): m._meta.verbose_name_raw for m in apps.get_models()} tp = mapper.get(resource_type) - queryset = queryset.filter(resource_type=tp) + queryset = queryset.filter(resource_type__in=[tp, resource_type]) return queryset class Meta: From e05930109bd4882429347a9b0a7cd4f9643ed2e8 Mon Sep 17 00:00:00 2001 From: fit2bot <68588906+fit2bot@users.noreply.github.com> Date: Mon, 2 Dec 2024 10:32:52 +0800 Subject: [PATCH 2/3] feat: PAM Service (#14552) * feat: PAM Service * perf: import package name --------- Co-authored-by: jiangweidong <1053570670@qq.com> --- apps/accounts/api/account/__init__.py | 1 + apps/accounts/api/account/account.py | 2 + apps/accounts/api/account/service.py | 74 ++++++++ apps/accounts/demos/go/README.en.md | 133 ++++++++++++++ apps/accounts/demos/go/README.ja.md | 133 ++++++++++++++ apps/accounts/demos/go/README.zh-hans.md | 133 ++++++++++++++ apps/accounts/demos/go/README.zh-hant.md | 133 ++++++++++++++ apps/accounts/demos/go/jms_pam.go | 162 ++++++++++++++++++ apps/accounts/demos/python/README.en.md | 96 +++++++++++ apps/accounts/demos/python/README.ja.md | 96 +++++++++++ apps/accounts/demos/python/README.zh-hans.md | 96 +++++++++++ apps/accounts/demos/python/README.zh-hant.md | 96 +++++++++++ .../accounts/demos/python/jms_pam/__init__.py | 1 + apps/accounts/demos/python/jms_pam/main.py | 145 ++++++++++++++++ apps/accounts/demos/python/setup.py | 22 +++ .../migrations/0015_serviceintegration.py | 41 +++++ apps/accounts/models/__init__.py | 1 + apps/accounts/models/service.py | 65 +++++++ apps/accounts/serializers/account/__init__.py | 1 + apps/accounts/serializers/account/service.py | 56 ++++++ apps/accounts/urls.py | 1 + apps/audits/api.py | 16 +- .../migrations/0004_serviceaccesslog.py | 26 +++ apps/audits/models.py | 13 +- apps/audits/serializers.py | 16 ++ apps/audits/urls/api_urls.py | 1 + apps/authentication/backends/drf.py | 37 +++- apps/authentication/models/access_key.py | 6 +- apps/common/auth/signature.py | 9 +- apps/common/db/fields.py | 1 + apps/common/db/utils.py | 4 + apps/common/drf/filters.py | 2 +- apps/common/serializers/fields.py | 1 + apps/i18n/lina/en.json | 4 + apps/i18n/lina/ja.json | 4 + apps/i18n/lina/zh.json | 4 + apps/i18n/lina/zh_hant.json | 6 +- apps/jumpserver/settings/libs.py | 1 + apps/users/models/user/__init__.py | 3 +- 39 files changed, 1630 insertions(+), 12 deletions(-) create mode 100644 apps/accounts/api/account/service.py create mode 100644 apps/accounts/demos/go/README.en.md create mode 100644 apps/accounts/demos/go/README.ja.md create mode 100644 apps/accounts/demos/go/README.zh-hans.md create mode 100644 apps/accounts/demos/go/README.zh-hant.md create mode 100644 apps/accounts/demos/go/jms_pam.go create mode 100644 apps/accounts/demos/python/README.en.md create mode 100644 apps/accounts/demos/python/README.ja.md create mode 100644 apps/accounts/demos/python/README.zh-hans.md create mode 100644 apps/accounts/demos/python/README.zh-hant.md create mode 100644 apps/accounts/demos/python/jms_pam/__init__.py create mode 100644 apps/accounts/demos/python/jms_pam/main.py create mode 100644 apps/accounts/demos/python/setup.py create mode 100644 apps/accounts/migrations/0015_serviceintegration.py create mode 100644 apps/accounts/models/service.py create mode 100644 apps/accounts/serializers/account/service.py create mode 100644 apps/audits/migrations/0004_serviceaccesslog.py diff --git a/apps/accounts/api/account/__init__.py b/apps/accounts/api/account/__init__.py index 7f90c23c7..17422761c 100644 --- a/apps/accounts/api/account/__init__.py +++ b/apps/accounts/api/account/__init__.py @@ -2,3 +2,4 @@ from .account import * from .task import * from .template import * from .virtual import * +from .service import * diff --git a/apps/accounts/api/account/account.py b/apps/accounts/api/account/account.py index 8cfeeec8c..d6ea0de28 100644 --- a/apps/accounts/api/account/account.py +++ b/apps/accounts/api/account/account.py @@ -12,6 +12,7 @@ from assets.models import Asset, Node from authentication.permissions import UserConfirmation, ConfirmType from common.api.mixin import ExtraFilterFieldsMixin from common.permissions import IsValidUser +from common.drf.filters import AttrRulesFilterBackend from orgs.mixins.api import OrgBulkModelViewSet from rbac.permissions import RBACPermission @@ -24,6 +25,7 @@ __all__ = [ class AccountViewSet(OrgBulkModelViewSet): model = Account search_fields = ('username', 'name', 'asset__name', 'asset__address', 'comment') + extra_filter_backends = [AttrRulesFilterBackend] filterset_class = AccountFilterSet serializer_classes = { 'default': serializers.AccountSerializer, diff --git a/apps/accounts/api/account/service.py b/apps/accounts/api/account/service.py new file mode 100644 index 000000000..dfb5f9028 --- /dev/null +++ b/apps/accounts/api/account/service.py @@ -0,0 +1,74 @@ +import os + +from django.utils.translation import gettext_lazy as _ +from django.conf import settings +from django.utils import translation +from rest_framework.decorators import action +from rest_framework.response import Response + +from accounts import serializers +from accounts.models import ServiceIntegration +from audits.models import ServiceAccessLog +from authentication.permissions import UserConfirmation, ConfirmType +from common.exceptions import JMSException +from common.permissions import IsValidUser +from common.utils import get_request_ip +from orgs.mixins.api import OrgBulkModelViewSet +from rbac.permissions import RBACPermission + + +class ServiceIntegrationViewSet(OrgBulkModelViewSet): + model = ServiceIntegration + search_fields = ('name', 'comment') + serializer_classes = { + 'default': serializers.ServiceIntegrationSerializer, + 'get_account_secret': serializers.ServiceAccountSecretSerializer + } + rbac_perms = { + 'get_once_secret': 'accounts.change_serviceintegration', + 'get_account_secret': 'accounts.view_serviceintegration', + } + + @action( + ['GET'], detail=False, url_path='sdks', + permission_classes=[IsValidUser] + ) + def get_sdks_info(self, request, *args, **kwargs): + readme = '' + sdk_language = self.request.query_params.get('language', 'python') + filename = f'readme.{translation.get_language()}.md' + readme_path = os.path.join( + settings.APPS_DIR, 'accounts', 'demos', sdk_language, filename + ) + if os.path.exists(readme_path): + with open(readme_path, 'r') as f: + readme = f.read() + return Response(data={'readme': readme }) + + @action( + ['GET'], detail=True, url_path='secret', + permission_classes=[RBACPermission, UserConfirmation.require(ConfirmType.MFA)] + ) + def get_once_secret(self, request, *args, **kwargs): + instance = self.get_object() + secret = instance.get_secret() + return Response(data={'id': instance.id, 'secret': secret}) + + @action(['GET'], detail=False, url_path='account-secret') + def get_account_secret(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.query_params) + if not serializer.is_valid(): + return Response({'error': serializer.errors}, status=400) + + service = request.user + account = service.get_account(**serializer.data) + if not account: + msg = _('Account not found') + raise JMSException(code='Not found', detail='%s' % msg) + asset = account.asset + ServiceAccessLog.objects.create( + 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})', + ) + return Response(data={'id': request.user.id, 'secret': account.secret}) + diff --git a/apps/accounts/demos/go/README.en.md b/apps/accounts/demos/go/README.en.md new file mode 100644 index 000000000..960b23f2d --- /dev/null +++ b/apps/accounts/demos/go/README.en.md @@ -0,0 +1,133 @@ +# JumpServer PAM Client + +This package provides a Go client for interacting with the JumpServer PAM API to retrieve secrets for various assets. It simplifies the process of sending requests and handling responses. + +## Features + +- Validate parameters before sending requests. +- Support for both asset and account-based secret retrieval. +- Easy integration with JumpServer PAM API using HMAC-SHA256 signatures for authentication. + +## Usage Instructions + +1. **Download Go Code Files**: + Download the code files into your project directory. + +2. **Import the Package**: + Import the package in your Go file, and you can directly use its functionalities. + +## Requirements + +- `Go 1.16+` +- `github.com/google/uuid` +- `gopkg.in/twindagger/httpsig.v1` + +## Usage + +### Initialization + +To use the JumpServer PAM client, create an instance by providing the required `endpoint`, `keyID`, and `keySecret`. + +```go +package main + +import ( + "fmt" + + "your_module_path/jms_pam" +) + +func main() { + client := jms_pam.NewJumpServerPAM( + "http://127.0.0.1", // Replace with your JumpServer endpoint + "your-key-id", // Replace with your actual Key ID + "your-key-secret", // Replace with your actual Key Secret + "", // Leave empty for default organization ID + ) +} +``` + +### Creating a Secret Request + +You can create a request for a secret by specifying the asset or account information. + +```go +request, err := jms_pam.NewSecretRequest("Linux", "", "root", "") +if err != nil { + fmt.Println("Error creating request:", err) + return +} +``` + +### Sending the Request + +Send the request using the `Send` method of the client. + +```go +secretObj, err := client.Send(request) +if err != nil { + fmt.Println("Error sending request:", err) + return +} +``` + +### Handling the Response + +Check if the secret was retrieved successfully and handle the response accordingly. + +```go +if secretObj.Valid { + fmt.Println("Secret:", secretObj.Secret) +} else { + fmt.Println("Get secret failed:", string(secretObj.Desc)) +} +``` + +### Complete Example + +Here’s a complete example of how to use the client: + +```go +package main + +import ( + "fmt" + + "your_module_path/jms_pam" +) + +func main() { + client := jms_pam.NewJumpServerPAM( + "http://127.0.0.1", + "your-key-id", + "your-key-secret", + "", + ) + + request, err := jms_pam.NewSecretRequest("Linux", "", "root", "") + if err != nil { + fmt.Println("Error creating request:", err) + return + } + + secretObj, err := client.Send(request) + if err != nil { + fmt.Println("Error sending request:", err) + return + } + + if secretObj.Valid { + fmt.Println("Secret:", secretObj.Secret) + } else { + fmt.Println("Get secret failed:", string(secretObj.Desc)) + } +} +``` + +## Error Handling + +The library returns errors for invalid parameters when creating a `SecretRequest`. This includes checks for valid UUIDs and ensuring that required parameters are provided. + +## Contributing + +Contributions are welcome! Please open an issue or submit a pull request for any enhancements or bug fixes. diff --git a/apps/accounts/demos/go/README.ja.md b/apps/accounts/demos/go/README.ja.md new file mode 100644 index 000000000..6539c4317 --- /dev/null +++ b/apps/accounts/demos/go/README.ja.md @@ -0,0 +1,133 @@ +# JumpServer PAM クライアント + +このパッケージは、JumpServer PAM API と対話し、さまざまな資産のパスワードを取得するための Go クライアントを提供します。リクエストの送信とレスポンスの処理を簡素化します。 + +## 機能 + +- リクエスト送信前にパラメータを検証します。 +- 資産およびアカウントに基づくパスワード取得をサポートします。 +- HMAC-SHA256 署名を使用して認証を行い、JumpServer PAM API との統合を容易にします。 + +## 使用手順 + +1. **Go コードファイルのダウンロード**: + コードファイルをプロジェクトディレクトリにダウンロードします。 + +2. **パッケージのインポート**: + Go ファイルにパッケージをインポートすると、その機能を直接使用できます。 + +## 要件 + +- `Go 1.16+` +- `github.com/google/uuid` +- `gopkg.in/twindagger/httpsig.v1` + +## 使用方法 + +### 初期化 + +JumpServer PAM クライアントを使用するには、必要な `endpoint`、`keyID`、および `keySecret` を提供してインスタンスを作成します。 + +```go +package main + +import ( + "fmt" + + "your_module_path/jms_pam" +) + +func main() { + client := jms_pam.NewJumpServerPAM( + "http://127.0.0.1", // あなたの JumpServer エンドポイントに置き換えてください + "your-key-id", // 実際の Key ID に置き換えてください + "your-key-secret", // 実際の Key Secret に置き換えてください + "", // デフォルトの組織 ID を使用するには空のままにします + ) +} +``` + +### パスワードリクエストの作成 + +資産またはアカウント情報を指定してリクエストを作成できます。 + +```go +request, err := jms_pam.NewSecretRequest("Linux", "", "root", "") +if err != nil { + fmt.Println("リクエスト作成中にエラー:", err) + return +} +``` + +### リクエストの送信 + +クライアントの `Send` メソッドを使用してリクエストを送信します。 + +```go +secretObj, err := client.Send(request) +if err != nil { + fmt.Println("リクエスト送信中にエラー:", err) + return +} +``` + +### レスポンスの処理 + +パスワードが正常に取得されたかどうかを確認し、それに応じてレスポンスを処理します。 + +```go +if secretObj.Valid { + fmt.Println("パスワード:", secretObj.Secret) +} else { + fmt.Println("パスワードの取得に失敗:", string(secretObj.Desc)) +} +``` + +### 完全な例 + +以下は、クライアントの使用方法に関する完全な例です。 + +```go +package main + +import ( + "fmt" + + "your_module_path/jms_pam" +) + +func main() { + client := jms_pam.NewJumpServerPAM( + "http://127.0.0.1", + "your-key-id", + "your-key-secret", + "", + ) + + request, err := jms_pam.NewSecretRequest("Linux", "", "root", "") + if err != nil { + fmt.Println("リクエスト作成中にエラー:", err) + return + } + + secretObj, err := client.Send(request) + if err != nil { + fmt.Println("リクエスト送信中にエラー:", err) + return + } + + if secretObj.Valid { + fmt.Println("パスワード:", secretObj.Secret) + } else { + fmt.Println("パスワードの取得に失敗:", string(secretObj.Desc)) + } +} +``` + +## エラーハンドリング + +このライブラリは、`SecretRequest` を作成する際に無効なパラメータに対するエラーを返します。これには、有効な UUID の確認や、必要なパラメータが提供されていることの確認が含まれます。 + +## 貢献 + +貢献を歓迎します!改善やバグ修正のために問題を提起したり、プルリクエストを送信してください。 diff --git a/apps/accounts/demos/go/README.zh-hans.md b/apps/accounts/demos/go/README.zh-hans.md new file mode 100644 index 000000000..5eaef3574 --- /dev/null +++ b/apps/accounts/demos/go/README.zh-hans.md @@ -0,0 +1,133 @@ +# JumpServer PAM 客户端 + +该包提供了一个 Go 客户端,用于与 JumpServer PAM API 交互,以检索各种资产的密码。它简化了发送请求和处理响应的过程。 + +## 功能 + +- 在发送请求之前验证参数。 +- 支持基于资产和账户的密码检索。 +- 使用 HMAC-SHA256 签名进行身份验证,方便与 JumpServer PAM API 集成。 + +## 使用说明 + +1. **下载 Go 代码文件**: + 将代码文件下载到您的项目目录中。 + +2. **导入包**: + 在您的 Go 文件中导入该包,您即可直接使用其功能。 + +## 需求 + +- `Go 1.16+` +- `github.com/google/uuid` +- `gopkg.in/twindagger/httpsig.v1` + +## 使用方法 + +### 初始化 + +要使用 JumpServer PAM 客户端,通过提供所需的 `endpoint`、`keyID` 和 `keySecret` 创建一个实例。 + +```go +package main + +import ( + "fmt" + + "your_module_path/jms_pam" +) + +func main() { + client := jms_pam.NewJumpServerPAM( + "http://127.0.0.1", // 替换为您的 JumpServer 端点 + "your-key-id", // 替换为您的实际 Key ID + "your-key-secret", // 替换为您的实际 Key Secret + "", // 留空以使用默认的组织 ID + ) +} +``` + +### 创建密码请求 + +您可以通过指定资产或账户信息来创建请求。 + +```go +request, err := jms_pam.NewSecretRequest("Linux", "", "root", "") +if err != nil { + fmt.Println("创建请求时出错:", err) + return +} +``` + +### 发送请求 + +使用客户端的 `Send` 方法发送请求。 + +```go +secretObj, err := client.Send(request) +if err != nil { + fmt.Println("发送请求时出错:", err) + return +} +``` + +### 处理响应 + +检查密码是否成功检索,并相应地处理响应。 + +```go +if secretObj.Valid { + fmt.Println("密码:", secretObj.Secret) +} else { + fmt.Println("获取密码失败:", string(secretObj.Desc)) +} +``` + +### 完整示例 + +以下是如何使用该客户端的完整示例: + +```go +package main + +import ( + "fmt" + + "your_module_path/jms_pam" +) + +func main() { + client := jms_pam.NewJumpServerPAM( + "http://127.0.0.1", + "your-key-id", + "your-key-secret", + "", + ) + + request, err := jms_pam.NewSecretRequest("Linux", "", "root", "") + if err != nil { + fmt.Println("创建请求时出错:", err) + return + } + + secretObj, err := client.Send(request) + if err != nil { + fmt.Println("发送请求时出错:", err) + return + } + + if secretObj.Valid { + fmt.Println("密码:", secretObj.Secret) + } else { + fmt.Println("获取密码失败:", string(secretObj.Desc)) + } +} +``` + +## 错误处理 + +该库会在创建 `SecretRequest` 时返回无效参数的错误。这包括对有效 UUID 的检查以及确保提供了必需的参数。 + +## 贡献 + +欢迎贡献!如有任何增强或错误修复,请提出问题或提交拉取请求。 diff --git a/apps/accounts/demos/go/README.zh-hant.md b/apps/accounts/demos/go/README.zh-hant.md new file mode 100644 index 000000000..1e6163191 --- /dev/null +++ b/apps/accounts/demos/go/README.zh-hant.md @@ -0,0 +1,133 @@ +# JumpServer PAM 客戶端 + +該包提供了一個 Go 客戶端,用於與 JumpServer PAM API 交互,以檢索各種資產的密碼。它簡化了發送請求和處理響應的過程。 + +## 功能 + +- 在發送請求之前驗證參數。 +- 支持基於資產和帳戶的密碼檢索。 +- 使用 HMAC-SHA256 簽名進行身份驗證,方便與 JumpServer PAM API 集成。 + +## 使用說明 + +1. **下載 Go 代碼文件**: + 將代碼文件下載到您的項目目錄中。 + +2. **導入包**: + 在您的 Go 文件中導入該包,您即可直接使用其功能。 + +## 需求 + +- `Go 1.16+` +- `github.com/google/uuid` +- `gopkg.in/twindagger/httpsig.v1` + +## 使用方法 + +### 初始化 + +要使用 JumpServer PAM 客戶端,通過提供所需的 `endpoint`、`keyID` 和 `keySecret` 創建一個實例。 + +```go +package main + +import ( + "fmt" + + "your_module_path/jms_pam" +) + +func main() { + client := jms_pam.NewJumpServerPAM( + "http://127.0.0.1", // 替換為您的 JumpServer 端點 + "your-key-id", // 替換為您的實際 Key ID + "your-key-secret", // 替換為您的實際 Key Secret + "", // 留空以使用默認的組織 ID + ) +} +``` + +### 創建密碼請求 + +您可以通過指定資產或帳戶信息來創建請求。 + +```go +request, err := jms_pam.NewSecretRequest("Linux", "", "root", "") +if err != nil { + fmt.Println("創建請求時出錯:", err) + return +} +``` + +### 發送請求 + +使用客戶端的 `Send` 方法發送請求。 + +```go +secretObj, err := client.Send(request) +if err != nil { + fmt.Println("發送請求時出錯:", err) + return +} +``` + +### 處理響應 + +檢查密碼是否成功檢索,並相應地處理響應。 + +```go +if secretObj.Valid { + fmt.Println("密碼:", secretObj.Secret) +} else { + fmt.Println("獲取密碼失敗:", string(secretObj.Desc)) +} +``` + +### 完整示例 + +以下是如何使用該客戶端的完整示例: + +```go +package main + +import ( + "fmt" + + "your_module_path/jms_pam" +) + +func main() { + client := jms_pam.NewJumpServerPAM( + "http://127.0.0.1", + "your-key-id", + "your-key-secret", + "", + ) + + request, err := jms_pam.NewSecretRequest("Linux", "", "root", "") + if err != nil { + fmt.Println("創建請求時出錯:", err) + return + } + + secretObj, err := client.Send(request) + if err != nil { + fmt.Println("發送請求時出錯:", err) + return + } + + if secretObj.Valid { + fmt.Println("密碼:", secretObj.Secret) + } else { + fmt.Println("獲取密碼失敗:", string(secretObj.Desc)) + } +} +``` + +## 錯誤處理 + +該庫會在創建 `SecretRequest` 時返回無效參數的錯誤。這包括對有效 UUID 的檢查以及確保提供了必需的參數。 + +## 貢獻 + +歡迎貢獻!如有任何增強或錯誤修復,請提出問題或提交拉取請求。 diff --git a/apps/accounts/demos/go/jms_pam.go b/apps/accounts/demos/go/jms_pam.go new file mode 100644 index 000000000..90a83fc46 --- /dev/null +++ b/apps/accounts/demos/go/jms_pam.go @@ -0,0 +1,162 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + + "github.com/google/uuid" + "gopkg.in/twindagger/httpsig.v1" +) + +const DefaultOrgId = "00000000-0000-0000-0000-000000000002" + +type RequestParamsError struct { + Params []string +} + +func (e *RequestParamsError) Error() string { + return fmt.Sprintf("At least one of the following fields must be provided: %v.", e.Params) +} + +type SecretRequest struct { + AccountID string + AssetID string + Asset string + Account string + Method string +} + +func NewSecretRequest(asset, assetID, account, accountID string) (*SecretRequest, error) { + req := &SecretRequest{ + Asset: asset, + AssetID: assetID, + Account: account, + AccountID: accountID, + Method: http.MethodGet, + } + + return req, req.validate() +} + +func (r *SecretRequest) validate() error { + if r.AccountID != "" { + if _, err := uuid.Parse(r.AccountID); err != nil { + return fmt.Errorf("invalid UUID: %s. Value must be a valid UUID", r.AccountID) + } + return nil + } + + if r.AssetID == "" && r.Asset == "" { + return &RequestParamsError{Params: []string{"asset", "asset_id"}} + } + + if r.Account == "" { + return &RequestParamsError{Params: []string{"account", "account_id"}} + } + + if r.AssetID != "" { + if _, err := uuid.Parse(r.AssetID); err != nil { + return fmt.Errorf("invalid UUID: %s. Value must be a valid UUID", r.AssetID) + } + } + return nil +} + +func (r *SecretRequest) GetURL() string { + return "/api/v1/accounts/service-integrations/account-secret/" +} + +func (r *SecretRequest) GetQuery() url.Values { + query := url.Values{} + if r.AccountID != "" { + query.Add("account_id", r.AccountID) + } + if r.AssetID != "" { + query.Add("asset_id", r.AssetID) + } + if r.Asset != "" { + query.Add("asset", r.Asset) + } + if r.Account != "" { + query.Add("account", r.Account) + } + return query +} + +type Secret struct { + Secret string `json:"secret,omitempty"` + Desc json.RawMessage `json:"desc,omitempty"` + Valid bool `json:"valid"` +} + +func FromResponse(response *http.Response) Secret { + var secret Secret + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + var raw json.RawMessage + if err := json.NewDecoder(response.Body).Decode(&raw); err == nil { + secret.Desc = raw + } else { + secret.Desc = json.RawMessage(`{"error": "Unknown error occurred"}`) + } + } else { + _ = json.NewDecoder(response.Body).Decode(&secret) + secret.Valid = true + } + return secret +} + +type JumpServerPAM struct { + Endpoint string + KeyID string + KeySecret string + OrgID string + httpClient *http.Client +} + +func NewJumpServerPAM(endpoint, keyID, keySecret, orgID string) *JumpServerPAM { + if orgID == "" { + orgID = DefaultOrgId + } + return &JumpServerPAM{ + Endpoint: endpoint, + KeyID: keyID, + KeySecret: keySecret, + OrgID: orgID, + httpClient: &http.Client{}, + } +} + +func (c *JumpServerPAM) SignRequest(r *http.Request) error { + headers := []string{"(request-target)", "date"} + signer, err := httpsig.NewRequestSigner(c.KeyID, c.KeySecret, "hmac-sha256") + if err != nil { + return err + } + return signer.SignRequest(r, headers, nil) +} + +func (c *JumpServerPAM) Send(req *SecretRequest) (Secret, error) { + fullUrl := c.Endpoint + req.GetURL() + query := req.GetQuery() + fullURL := fmt.Sprintf("%s?%s", fullUrl, query.Encode()) + + request, err := http.NewRequest(req.Method, fullURL, nil) + if err != nil { + return Secret{}, err + } + request.Header.Set("Accept", "application/json") + request.Header.Set("X-Source", "jms-pam") + err = c.SignRequest(request) + if err != nil { + return Secret{Desc: json.RawMessage(`{"error": "` + err.Error() + `"}`)}, nil + } + response, err := c.httpClient.Do(request) + if err != nil { + return Secret{Desc: json.RawMessage(`{"error": "` + err.Error() + `"}`)}, nil + } + + return FromResponse(response), nil +} diff --git a/apps/accounts/demos/python/README.en.md b/apps/accounts/demos/python/README.en.md new file mode 100644 index 000000000..e996b6576 --- /dev/null +++ b/apps/accounts/demos/python/README.en.md @@ -0,0 +1,96 @@ +# JumpServer PAM Client + +This package provides a Python client for interacting with the JumpServer PAM API to retrieve secrets for various assets. It simplifies the process of sending requests and handling responses. + +## Features + +- Validate parameters before sending requests. +- Support for both asset and account-based secret retrieval. +- Easy integration with JumpServer PAM API using HTTP signatures for authentication. + +## Installation + +You can install the package via pip: + +```bash +pip install jms_pam-0.0.1-py3-none-any.whl +``` + +## Requirements + +- `Python 3.6+` +- `requests` +- `httpsig` + +## Usage + +### Initialization + +To use the JumpServer PAM client, create an instance by providing the required `endpoint`, `key_id`, and `key_secret`. + +```python +from jms_pam import JumpServerPAM, SecretRequest + +client = JumpServerPAM( + endpoint='http://127.0.0.1', + key_id='your-key-id', + key_secret='your-key-secret' +) +``` + +### Creating a Secret Request + +You can create a request for a secret by specifying the asset or account information. + +```python +request = SecretRequest(asset='Linux', account='root') +``` + +### Sending the Request + +Send the request using the `send` method of the client. + +```python +secret_obj = client.send(request) +``` + +### Handling the Response + +Check if the secret was retrieved successfully and handle the response accordingly. + +```python +if secret_obj.valid: + print('Secret: %s' % secret_obj.secret) +else: + print('Get secret failed: %s' % secret_obj.desc) +``` + +### Complete Example + +Here’s a complete example of how to use the client: + +```python +from jumpserver_pam_client import JumpServerPAM, SecretRequest + +client = JumpServerPAM( + endpoint='http://127.0.0.1', + key_id='your-key-id', + key_secret='your-key-secret' +) + +request = SecretRequest(asset='Linux', account='root') +secret_obj = client.send(request) + +if secret_obj.valid: + print('Secret: %s' % secret_obj.secret) +else: + print('Get secret failed: %s' % secret_obj.desc) +``` + +## Error Handling + +The library raises `RequestParamsError` if the parameters provided do not meet the validation requirements. This includes checks for valid UUIDs and interdependencies between parameters. + +## Contributing + +Contributions are welcome! Please open an issue or submit a pull request for any enhancements or bug fixes. diff --git a/apps/accounts/demos/python/README.ja.md b/apps/accounts/demos/python/README.ja.md new file mode 100644 index 000000000..346ac2bc1 --- /dev/null +++ b/apps/accounts/demos/python/README.ja.md @@ -0,0 +1,96 @@ +# JumpServer PAM クライアント + +このパッケージは、JumpServer PAM API と対話し、さまざまなアセットのシークレットを取得するための Python クライアントを提供します。リクエストを送信し、レスポンスを処理するプロセスを簡素化します。 + +## 特徴 + +- リクエストを送信する前にパラメータを検証します。 +- アセットおよびアカウントベースのシークレット取得をサポートします。 +- HTTP 署名を使用して JumpServer PAM API と簡単に統合できます。 + +## インストール + +以下のコマンドを使用して、パッケージを pip でインストールできます: + +```bash +pip install jms_pam-0.0.1-py3-none-any.whl +``` + +## 要件 + +- `Python 3.6+` +- `requests` +- `httpsig` + +## 使用方法 + +### 初期化 + +JumpServer PAM クライアントを使用するには、必要な `endpoint`、`key_id`、および `key_secret` を提供してインスタンスを作成します。 + +```python +from jms_pam import JumpServerPAM, SecretRequest + +client = JumpServerPAM( + endpoint='http://127.0.0.1', + key_id='your-key-id', + key_secret='your-key-secret' +) +``` + +### シークレットリクエストの作成 + +アセットまたはアカウント情報を指定して、シークレットのリクエストを作成できます。 + +```python +request = SecretRequest(asset='Linux', account='root') +``` + +### リクエストの送信 + +クライアントの `send` メソッドを使用してリクエストを送信します。 + +```python +secret_obj = client.send(request) +``` + +### レスポンスの処理 + +シークレットが正常に取得されたかどうかを確認し、レスポンスを適切に処理します。 + +```python +if secret_obj.valid: + print('秘密: %s' % secret_obj.secret) +else: + print('シークレットの取得に失敗しました: %s' % secret_obj.desc) +``` + +### 完全な例 + +以下は、クライアントの使用方法の完全な例です: + +```python +from jumpserver_pam_client import JumpServerPAM, SecretRequest + +client = JumpServerPAM( + endpoint='http://127.0.0.1', + key_id='your-key-id', + key_secret='your-key-secret' +) + +request = SecretRequest(asset='Linux', account='root') +secret_obj = client.send(request) + +if secret_obj.valid: + print('秘密: %s' % secret_obj.secret) +else: + print('シークレットの取得に失敗しました: %s' % secret_obj.desc) +``` + +## エラーハンドリング + +ライブラリは、提供されたパラメータが検証要件を満たしていない場合に `RequestParamsError` を発生させます。これには、有効な UUID の確認やパラメータ間の相互依存性のチェックが含まれます。 + +## 貢献 + +貢献を歓迎します!改善やバグ修正のために、問題を開くかプルリクエストを送信してください。 diff --git a/apps/accounts/demos/python/README.zh-hans.md b/apps/accounts/demos/python/README.zh-hans.md new file mode 100644 index 000000000..b7dcc5121 --- /dev/null +++ b/apps/accounts/demos/python/README.zh-hans.md @@ -0,0 +1,96 @@ +# JumpServer PAM 客户端 + +该包提供了一个 Python 客户端,用于与 JumpServer PAM API 交互,以检索各种资产的密码。它简化了发送请求和处理响应的过程。 + +## 特性 + +- 在发送请求之前验证参数。 +- 支持基于资产和账户的密码检索。 +- 通过 HTTP 签名轻松集成 JumpServer PAM API。 + +## 安装 + +您可以通过 pip 安装该包: + +```bash +pip install jms_pam-0.0.1-py3-none-any.whl +``` + +## 需求 + +- `Python 3.6+` +- `requests` +- `httpsig` + +## 使用方法 + +### 初始化 + +要使用 JumpServer PAM 客户端,通过提供所需的 `endpoint`、`key_id` 和 `key_secret` 创建一个实例。 + +```python +from jms_pam import JumpServerPAM, SecretRequest + +client = JumpServerPAM( + endpoint='http://127.0.0.1', + key_id='your-key-id', + key_secret='your-key-secret' +) +``` + +### 创建密码请求 + +您可以通过指定资产或账户信息来创建一个密码请求。 + +```python +request = SecretRequest(asset='Linux', account='root') +``` + +### 发送请求 + +使用客户端的 `send` 方法发送请求。 + +```python +secret_obj = client.send(request) +``` + +### 处理响应 + +检查密码是否成功检索,并相应地处理响应。 + +```python +if secret_obj.valid: + print('密码: %s' % secret_obj.secret) +else: + print('获取密码失败: %s' % secret_obj.desc) +``` + +### 完整示例 + +以下是如何使用该客户端的完整示例: + +```python +from jms_pam import JumpServerPAM, SecretRequest + +client = JumpServerPAM( + endpoint='http://127.0.0.1', + key_id='your-key-id', + key_secret='your-key-secret' +) + +request = SecretRequest(asset='Linux', account='root') +secret_obj = client.send(request) + +if secret_obj.valid: + print('密码: %s' % secret_obj.secret) +else: + print('获取密码失败: %s' % secret_obj.desc) +``` + +## 错误处理 + +如果提供的参数不符合验证要求,库会引发 `RequestParamsError`。这包括对有效 UUID 的检查和参数之间的相互依赖性检查。 + +## 贡献 + +欢迎贡献!请打开一个问题或提交拉取请求,以进行任何增强或修复错误。 diff --git a/apps/accounts/demos/python/README.zh-hant.md b/apps/accounts/demos/python/README.zh-hant.md new file mode 100644 index 000000000..538cc6c83 --- /dev/null +++ b/apps/accounts/demos/python/README.zh-hant.md @@ -0,0 +1,96 @@ +# JumpServer PAM 客戶端 + +此套件提供了一個 Python 客戶端,用於與 JumpServer PAM API 互動,以檢索各種資產的秘密。它簡化了發送請求和處理響應的過程。 + +## 特性 + +- 在發送請求之前驗證參數。 +- 支持基於資產和帳戶的秘密檢索。 +- 通過 HTTP 簽名輕鬆集成 JumpServer PAM API。 + +## 安裝 + +您可以通過 pip 安裝此套件: + +```bash +pip install jms_pam-0.0.1-py3-none-any.whl +``` + +## 需求 + +- `Python 3.6+` +- `requests` +- `httpsig` + +## 使用方法 + +### 初始化 + +要使用 JumpServer PAM 客戶端,通過提供所需的 `endpoint`、`key_id` 和 `key_secret` 創建一個實例。 + +```python +from jms_pam import JumpServerPAM, SecretRequest + +client = JumpServerPAM( + endpoint='http://127.0.0.1', + key_id='your-key-id', + key_secret='your-key-secret' +) +``` + +### 創建秘密請求 + +您可以通過指定資產或帳戶信息來創建一個秘密請求。 + +```python +request = SecretRequest(asset='Linux', account='root') +``` + +### 發送請求 + +使用客戶端的 `send` 方法發送請求。 + +```python +secret_obj = client.send(request) +``` + +### 處理響應 + +檢查秘密是否成功檢索,並相應地處理響應。 + +```python +if secret_obj.valid: + print('秘密: %s' % secret_obj.secret) +else: + print('獲取秘密失敗: %s' % secret_obj.desc) +``` + +### 完整示例 + +以下是如何使用該客戶端的完整示例: + +```python +from jumpserver_pam_client import JumpServerPAM, SecretRequest + +client = JumpServerPAM( + endpoint='http://127.0.0.1', + key_id='your-key-id', + key_secret='your-key-secret' +) + +request = SecretRequest(asset='Linux', account='root') +secret_obj = client.send(request) + +if secret_obj.valid: + print('秘密: %s' % secret_obj.secret) +else: + print('獲取秘密失敗: %s' % secret_obj.desc) +``` + +## 錯誤處理 + +如果提供的參數不符合驗證要求,該庫將引發 `RequestParamsError`。這包括對有效 UUID 的檢查和參數之間的相互依賴性檢查。 + +## 貢獻 + +歡迎貢獻!請打開一個問題或提交拉取請求,以進行任何增強或修復錯誤。 diff --git a/apps/accounts/demos/python/jms_pam/__init__.py b/apps/accounts/demos/python/jms_pam/__init__.py new file mode 100644 index 000000000..020ccb6b4 --- /dev/null +++ b/apps/accounts/demos/python/jms_pam/__init__.py @@ -0,0 +1 @@ +from .main import JumpServerPAM, SecretRequest diff --git a/apps/accounts/demos/python/jms_pam/main.py b/apps/accounts/demos/python/jms_pam/main.py new file mode 100644 index 000000000..7cacd1fc2 --- /dev/null +++ b/apps/accounts/demos/python/jms_pam/main.py @@ -0,0 +1,145 @@ +import requests +import uuid + +from datetime import datetime +from urllib.parse import urlencode +from httpsig.requests_auth import HTTPSignatureAuth +from requests.exceptions import RequestException + + +DEFAULT_ORG_ID = '00000000-0000-0000-0000-000000000002' + + +class RequestParamsError(ValueError): + def __init__(self, params): + self.params = params + + def __str__(self): + msg = "At least one of the following fields must be provided: %s." + return 'RequestParamsError: (%s)' % msg % ', '.join(self.params) + + +class SecretRequest(object): + """ + Validate parameters and their interdependencies. + Parameters: + account_id (str): The account ID, must be a valid UUID. + asset_id (str): The asset ID, must be a valid UUID. + asset (str): The name of the asset, can be empty. + account (str): The name of the account, can be empty. + + Validation Logic: + - When 'account_id' is provided, 'asset', 'asset_id', and 'account' must not be provided. + - When 'account' is provided, either 'asset' or 'asset_id' must be provided. + - It is not allowed to provide both 'account_id' and 'asset_id' together. + + Raises: + ValueError: If the parameters do not meet the requirements, a detailed error message will be raised. + """ + def __init__(self, asset='', asset_id='', account='', account_id=''): + self.account_id = account_id + self.asset_id = asset_id + self.asset = asset + self.account = account + self.method = 'get' + self._init_check() + + @staticmethod + def _valid_uuid(value): + if not value: + return + + try: + uuid.UUID(str(value)) + except (ValueError, TypeError): + raise ValueError('Invalid UUID: %s. Value must be a valid UUID.' % value) + + def _init_check(self): + for id_value in [self.account_id, self.asset_id]: + self._valid_uuid(id_value) + + if self.account_id: + return + + if not self.asset_id and not self.asset: + raise RequestParamsError(['asset', 'asset_id']) + + if not self.account: + raise RequestParamsError(['account', 'account_id']) + + @staticmethod + def get_url(): + return '/api/v1/accounts/service-integrations/account-secret/' + + def get_query(self): + return {k: getattr(self, k) for k in vars(self) if getattr(self, k)} + + +class Secret(object): + def __init__(self, secret='', desc=''): + self.secret = secret + self.desc = desc + self.valid = not desc + + @classmethod + def from_exception(cls, e): + return cls(desc=str(e)) + + @classmethod + def from_response(cls, response): + secret, error = '', '' + try: + data = response.json() + if response.status_code != 200: + for k, v in data.items(): + error += '%s: %s; ' % (k, v) + secret = data.get('secret') + except Exception as e: + error = str(e) + return cls(secret=secret, desc=error) + + +class JumpServerPAM(object): + def __init__(self, endpoint, key_id, key_secret, org_id=DEFAULT_ORG_ID): + self.endpoint = endpoint + self.key_id = key_id + self.key_secret = key_secret + self.org_id = org_id + self._auth = None + + @property + def headers(self): + gmt_form = '%a, %d %b %Y %H:%M:%S GMT' + return { + 'Accept': 'application/json', + 'X-JMS-ORG': self.org_id, + 'Date': datetime.utcnow().strftime(gmt_form), + 'X-Source': 'jms-pam' + } + + def _build_url(self, url, query_params=None): + query_params = query_params or {} + endpoint = self.endpoint[:-1] if self.endpoint.endswith('/') else self.endpoint + return '%s%s?%s' % (endpoint, url, urlencode(query_params)) + + def _get_auth(self): + if self._auth is None: + signature_headers = ['(request-target)', 'accept', 'date'] + self._auth = HTTPSignatureAuth( + key_id=self.key_id, secret=self.key_secret, + algorithm='hmac-sha256', headers=signature_headers + ) + return self._auth + + def send(self, secret_request): + try: + url = secret_request.get_url() + query_params = secret_request.get_query() + request_method = getattr(requests, secret_request.method) + response = request_method( + self._build_url(url, query_params), + auth=self._get_auth(), headers=self.headers + ) + except RequestException as e: + return Secret.from_exception(e) + return Secret.from_response(response) diff --git a/apps/accounts/demos/python/setup.py b/apps/accounts/demos/python/setup.py new file mode 100644 index 000000000..e77292bdc --- /dev/null +++ b/apps/accounts/demos/python/setup.py @@ -0,0 +1,22 @@ +from setuptools import setup, find_packages + + +setup( + name='jms-pam', + version='0.0.1', + packages=find_packages(), + install_requires=[ + 'requests', + 'httpsig' + ], + description='JumpServer PAM Client', + long_description=open('README.md').read(), + long_description_content_type='text/markdown', + url='https://github.com/jumpserver', + author='JumpServer Team', + author_email='code@jumpserver.org', + classifiers=[ + 'Programming Language :: Python :: 3', + ], + python_requires='>=3.6', +) diff --git a/apps/accounts/migrations/0015_serviceintegration.py b/apps/accounts/migrations/0015_serviceintegration.py new file mode 100644 index 000000000..5343d67a3 --- /dev/null +++ b/apps/accounts/migrations/0015_serviceintegration.py @@ -0,0 +1,41 @@ +# Generated by Django 4.1.13 on 2024-11-29 14:41 + +import common.db.fields +import common.db.utils +from django.db import migrations, models +import private_storage.fields +import private_storage.storage.files +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0014_gatheraccountsautomation_check_risk'), + ] + + operations = [ + migrations.CreateModel( + name='ServiceIntegration', + 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)), + ('org_id', models.CharField(blank=True, db_index=True, default='', max_length=36, verbose_name='Organization')), + ('name', models.CharField(max_length=128, verbose_name='Name')), + ('logo_image', private_storage.fields.PrivateImageField(max_length=128, storage=private_storage.storage.files.PrivateFileSystemStorage(), upload_to='service-integration', verbose_name='Logo')), + ('secret', common.db.fields.EncryptTextField(default='', verbose_name='Secret')), + ('accounts', common.db.fields.JSONManyToManyField(default=dict, to='accounts.Account', verbose_name='Accounts')), + ('ip_group', models.JSONField(default=common.db.utils.default_ip_group, verbose_name='IP group')), + ('date_last_used', models.DateTimeField(blank=True, null=True, verbose_name='Date last used')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ], + options={ + 'verbose_name': 'Service integration', + 'unique_together': {('name', 'org_id')}, + }, + ), + ] diff --git a/apps/accounts/models/__init__.py b/apps/accounts/models/__init__.py index 5ec98bb68..2b7aaf750 100644 --- a/apps/accounts/models/__init__.py +++ b/apps/accounts/models/__init__.py @@ -3,3 +3,4 @@ from .base import * # noqa from .automations import * # noqa from .template import * # noqa from .virtual import * # noqa +from .service import * # noqa diff --git a/apps/accounts/models/service.py b/apps/accounts/models/service.py new file mode 100644 index 000000000..8aa3bc528 --- /dev/null +++ b/apps/accounts/models/service.py @@ -0,0 +1,65 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ +from private_storage.fields import PrivateImageField + +from accounts.models import Account +from common.db import fields +from common.db.fields import JSONManyToManyField, RelatedManager +from common.db.utils import default_ip_group +from common.utils import random_string +from orgs.mixins.models import JMSOrgBaseModel + + +class ServiceIntegration(JMSOrgBaseModel): + is_anonymous = False + + name = models.CharField(max_length=128, unique=False, verbose_name=_('Name')) + logo_image = PrivateImageField( + upload_to='service-integration', max_length=128, verbose_name=_('Logo') + ) + secret = fields.EncryptTextField(default='', verbose_name=_('Secret')) + accounts = JSONManyToManyField('accounts.Account', default=dict, verbose_name=_('Accounts')) + ip_group = models.JSONField(default=default_ip_group, verbose_name=_('IP group')) + date_last_used = models.DateTimeField(null=True, blank=True, verbose_name=_('Date last used')) + is_active = models.BooleanField(default=True, verbose_name=_('Active')) + + class Meta: + unique_together = [('name', 'org_id')] + verbose_name = _('Service integration') + + @property + def accounts_amount(self): + qs = Account.objects.all() + query = RelatedManager.get_to_filter_qs(self.accounts.value, Account) + return qs.filter(*query).count() + + @property + def is_valid(self): + return self.is_active + + @property + def is_authenticated(self): + return self.is_active + + @staticmethod + def has_perms(perms): + support_perms = ['accounts.view_serviceintegration'] + return all([perm in support_perms for perm in perms]) + + def get_secret(self): + self.secret = random_string(36) + self.save(update_fields=['secret']) + return self.secret + + def get_account(self, asset='', asset_id='', account='', account_id=''): + qs = Account.objects.all() + if account_id: + qs = qs.filter(id=account_id) + elif account: + qs = qs.filter(name=account) + if asset_id: + qs = qs.filter(asset_id=asset_id) + elif asset: + qs = qs.filter(asset__name=asset) + query = RelatedManager.get_to_filter_qs(self.accounts.value, Account) + return qs.filter(*query).distinct().first() diff --git a/apps/accounts/serializers/account/__init__.py b/apps/accounts/serializers/account/__init__.py index 556f346df..3651f13eb 100644 --- a/apps/accounts/serializers/account/__init__.py +++ b/apps/accounts/serializers/account/__init__.py @@ -3,3 +3,4 @@ from .backup import * from .base import * from .template import * from .virtual import * +from .service import * diff --git a/apps/accounts/serializers/account/service.py b/apps/accounts/serializers/account/service.py new file mode 100644 index 000000000..220be4f7b --- /dev/null +++ b/apps/accounts/serializers/account/service.py @@ -0,0 +1,56 @@ +from django.utils.translation import gettext_lazy as _ +from rest_framework import serializers + +from accounts.models import ServiceIntegration +from acls.serializers.rules import ip_group_child_validator, ip_group_help_text +from common.serializers.fields import JSONManyToManyField + + +class ServiceIntegrationSerializer(serializers.ModelSerializer): + accounts = JSONManyToManyField(label=_('Account')) + ip_group = serializers.ListField( + default=['*'], label=_('Access IP'), help_text=ip_group_help_text, + child=serializers.CharField(max_length=1024, validators=[ip_group_child_validator]) + ) + + class Meta: + model = ServiceIntegration + fields_mini = ['id', 'name'] + fields_small = fields_mini + ['logo_image', 'accounts'] + fields = fields_small + [ + 'date_last_used', 'date_created', 'date_updated', + 'ip_group', 'accounts_amount', 'comment', 'is_active' + ] + extra_kwargs = { + 'comment': {'label': _('Comment')}, + 'name': {'label': _('Name')}, + 'is_active': {'default': True}, + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + request_method = self.context.get('request').method + if request_method == 'PUT': + self.fields['logo_image'].required = False + + +class ServiceAccountSecretSerializer(serializers.Serializer): + asset = serializers.CharField(required=False, allow_blank=True) + asset_id = serializers.UUIDField(required=False, allow_null=True) + account = serializers.CharField(required=False, allow_blank=True) + account_id = serializers.UUIDField(required=False, allow_null=True) + + @staticmethod + def _valid_at_least_one(attrs, fields): + if not any(attrs.get(field) for field in fields): + raise serializers.ValidationError( + f"At least one of the following fields must be provided: {', '.join(fields)}." + ) + + def validate(self, attrs): + if attrs.get('account_id'): + return attrs + + self._valid_at_least_one(attrs, ['asset', 'asset_id']) + self._valid_at_least_one(attrs, ['account', 'account_id']) + return attrs diff --git a/apps/accounts/urls.py b/apps/accounts/urls.py index 49edc6dac..352dcfaae 100644 --- a/apps/accounts/urls.py +++ b/apps/accounts/urls.py @@ -9,6 +9,7 @@ app_name = 'accounts' router = BulkRouter() router.register(r'accounts', api.AccountViewSet, 'account') +router.register(r'service-integrations', api.ServiceIntegrationViewSet, 'service-integration') router.register(r'virtual-accounts', api.VirtualAccountViewSet, 'virtual-account') router.register(r'gathered-accounts', api.GatheredAccountViewSet, 'gathered-account') router.register(r'account-secrets', api.AccountSecretsViewSet, 'account-secret') diff --git a/apps/audits/api.py b/apps/audits/api.py index cd1513c5c..75ab3a334 100644 --- a/apps/audits/api.py +++ b/apps/audits/api.py @@ -33,13 +33,13 @@ from .const import ActivityChoices from .filters import UserSessionFilterSet, OperateLogFilterSet from .models import ( FTPLog, UserLoginLog, OperateLog, PasswordChangeLog, - ActivityLog, JobLog, UserSession + ActivityLog, JobLog, UserSession, ServiceAccessLog ) from .serializers import ( FTPLogSerializer, UserLoginLogSerializer, JobLogSerializer, OperateLogSerializer, OperateLogActionDetailSerializer, PasswordChangeLogSerializer, ActivityUnionLogSerializer, - FileSerializer, UserSessionSerializer + FileSerializer, UserSessionSerializer, ServiceAccessLogSerializer ) from .utils import construct_userlogin_usernames @@ -290,3 +290,15 @@ class UserSessionViewSet(CommonApiMixin, viewsets.ModelViewSet): user_session_manager.remove(key) queryset.delete() return Response(status=status.HTTP_200_OK) + + +class ServiceAccessLogViewSet(OrgReadonlyModelViewSet): + model = ServiceAccessLog + serializer_class = ServiceAccessLogSerializer + extra_filter_backends = [DatetimeRangeFilterBackend] + date_range_filter_fields = [ + ('datetime', ('date_from', 'date_to')) + ] + filterset_fields = ['account', 'remote_addr', 'service_id'] + search_fields = filterset_fields + ordering = ['-datetime'] diff --git a/apps/audits/migrations/0004_serviceaccesslog.py b/apps/audits/migrations/0004_serviceaccesslog.py new file mode 100644 index 000000000..2d753e0df --- /dev/null +++ b/apps/audits/migrations/0004_serviceaccesslog.py @@ -0,0 +1,26 @@ +# Generated by Django 4.1.13 on 2024-11-28 09:48 + +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('audits', '0003_auto_20180816_1652'), + ] + + operations = [ + migrations.CreateModel( + name='ServiceAccessLog', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('remote_addr', models.GenericIPAddressField(verbose_name='Remote addr')), + ('service', models.CharField(max_length=128, verbose_name='Service')), + ('service_id', models.UUIDField(verbose_name='Service ID')), + ('asset', models.CharField(max_length=128, verbose_name='Asset')), + ('account', models.CharField(max_length=128, verbose_name='Account')), + ('datetime', models.DateTimeField(auto_now=True, verbose_name='Datetime')), + ], + ), + ] diff --git a/apps/audits/models.py b/apps/audits/models.py index a06724803..4d0bc107f 100644 --- a/apps/audits/models.py +++ b/apps/audits/models.py @@ -33,7 +33,8 @@ __all__ = [ "PasswordChangeLog", "UserLoginLog", "JobLog", - "UserSession" + "UserSession", + "ServiceAccessLog", ] @@ -301,3 +302,13 @@ class UserSession(models.Model): permissions = [ ('offline_usersession', _('Offline user session')), ] + + +class ServiceAccessLog(models.Model): + id = models.UUIDField(default=uuid.uuid4, primary_key=True) + remote_addr = models.GenericIPAddressField(verbose_name=_("Remote addr")) + service = models.CharField(max_length=128, verbose_name=_("Service")) + service_id = models.UUIDField(verbose_name=_("Service ID")) + asset = models.CharField(max_length=128, verbose_name=_("Asset")) + account = models.CharField(max_length=128, verbose_name=_("Account")) + datetime = models.DateTimeField(auto_now=True, verbose_name=_("Datetime")) diff --git a/apps/audits/serializers.py b/apps/audits/serializers.py index d2a019e4c..916785a93 100644 --- a/apps/audits/serializers.py +++ b/apps/audits/serializers.py @@ -189,3 +189,19 @@ class UserSessionSerializer(serializers.ModelSerializer): if not request: return False return request.session.session_key == obj.key + + +class ServiceAccessLogSerializer(serializers.ModelSerializer): + class Meta: + model = models.ServiceAccessLog + fields_mini = ['id'] + fields_small = fields_mini + [ + 'remote_addr', 'service', 'service_id', 'asset', 'account', 'datetime' + ] + fields = fields_small + extra_kwargs = { + 'remote_addr': {'label': _('Remote Address')}, + 'asset': {'label': _('Asset')}, + 'account': {'label': _('Account')}, + 'datetime': {'label': _('Datetime')}, + } diff --git a/apps/audits/urls/api_urls.py b/apps/audits/urls/api_urls.py index 765470afb..f84a8e78f 100644 --- a/apps/audits/urls/api_urls.py +++ b/apps/audits/urls/api_urls.py @@ -16,6 +16,7 @@ router.register(r'password-change-logs', api.PasswordChangeLogViewSet, 'password router.register(r'job-logs', api.JobAuditViewSet, 'job-log') router.register(r'my-login-logs', api.MyLoginLogViewSet, 'my-login-log') router.register(r'user-sessions', api.UserSessionViewSet, 'user-session') +router.register(r'service-access-logs', api.ServiceAccessLogViewSet, 'service-access-log') urlpatterns = [ path('activities/', api.ResourceActivityAPIView.as_view(), name='resource-activities'), diff --git a/apps/authentication/backends/drf.py b/apps/authentication/backends/drf.py index 4cf2577c1..2503061da 100644 --- a/apps/authentication/backends/drf.py +++ b/apps/authentication/backends/drf.py @@ -7,9 +7,10 @@ from django.utils import timezone from django.utils.translation import gettext as _ from rest_framework import authentication, exceptions +from accounts.models import ServiceIntegration from common.auth import signature from common.decorators import merge_delay_run -from common.utils import get_object_or_none, get_request_ip_or_data, contains_ip +from common.utils import get_object_or_none, get_request_ip_or_data, contains_ip, get_request_ip from users.models import User from ..models import AccessKey, PrivateToken @@ -33,6 +34,13 @@ def update_user_last_used(users=()): User.objects.filter(id__in=users).update(date_api_key_last_used=timezone.now()) +@merge_delay_run(ttl=60) +def update_service_integration_last_used(service_integrations=()): + ServiceIntegration.objects.filter( + id__in=service_integrations + ).update(date_last_used=timezone.now()) + + def after_authenticate_update_date(user, token=None): update_user_last_used.delay(users=(user.id,)) if token: @@ -146,3 +154,30 @@ class SignatureAuthentication(signature.SignatureAuthentication): return True except (AccessKey.DoesNotExist, exceptions.ValidationError): return False + + +class ServiceAuthentication(signature.SignatureAuthentication): + __instance = None + source = 'jms-pam' + + def get_object(self, key_id): + if not self.__instance: + self.__instance = ServiceIntegration.objects.filter( + id=key_id, is_active=True, + ).first() + return self.__instance + + def fetch_user_data(self, key_id, algorithm=None): + obj = self.get_object(key_id) + if not obj: + return None, None + return obj, obj.secret + + def is_ip_allow(self, key_id, request): + obj = self.get_object(key_id) + if not contains_ip(get_request_ip(request), obj.ip_group): + return False + return True + + def after_authenticate_update_date(self, user): + update_service_integration_last_used.delay((user.id,)) diff --git a/apps/authentication/models/access_key.py b/apps/authentication/models/access_key.py index aa2748769..f02744140 100644 --- a/apps/authentication/models/access_key.py +++ b/apps/authentication/models/access_key.py @@ -5,6 +5,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ import common.db.models + +from common.db.utils import default_ip_group from common.utils.random import random_string @@ -12,10 +14,6 @@ def default_secret(): return random_string(36) -def default_ip_group(): - return ["*"] - - class AccessKey(models.Model): id = models.UUIDField(verbose_name='AccessKeyID', primary_key=True, default=uuid.uuid4, editable=False) secret = models.CharField(verbose_name='AccessKeySecret', default=default_secret, max_length=36) diff --git a/apps/common/auth/signature.py b/apps/common/auth/signature.py index c1e392f30..461066966 100644 --- a/apps/common/auth/signature.py +++ b/apps/common/auth/signature.py @@ -36,7 +36,7 @@ class SignatureAuthentication(authentication.BaseAuthentication): :param www_authenticate_realm: Default: "api" :param required_headers: Default: ["(request-target)", "date"] """ - + source = '' www_authenticate_realm = "api" required_headers = ["(request-target)", "date"] @@ -47,6 +47,9 @@ class SignatureAuthentication(authentication.BaseAuthentication): def is_ip_allow(self, key_id, request): raise NotImplementedError() + def after_authenticate_update_date(self, user): + pass + def authenticate_header(self, request): """ DRF sends this for unauthenticated responses if we're the primary @@ -74,6 +77,9 @@ class SignatureAuthentication(authentication.BaseAuthentication): if method.lower() != 'signature': return None + if self.source and request.META.get('HTTP_X_SOURCE') != self.source: + return None + # Verify basic header structure. if len(fields) == 0: raise FAILED @@ -117,4 +123,5 @@ class SignatureAuthentication(authentication.BaseAuthentication): if not hs.verify(): raise FAILED + self.after_authenticate_update_date(user) return user, fields["keyid"] diff --git a/apps/common/db/fields.py b/apps/common/db/fields.py index 0bbe08f18..791a03b5f 100644 --- a/apps/common/db/fields.py +++ b/apps/common/db/fields.py @@ -39,6 +39,7 @@ __all__ = [ "BitChoices", "TreeChoices", "JSONManyToManyField", + "RelatedManager", ] diff --git a/apps/common/db/utils.py b/apps/common/db/utils.py index f2fc5fa8a..c127e84aa 100644 --- a/apps/common/db/utils.py +++ b/apps/common/db/utils.py @@ -8,6 +8,10 @@ from common.utils import get_logger, signer, crypto logger = get_logger(__file__) +def default_ip_group(): + return ["*"] + + def get_object_if_need(model, pk): if not isinstance(pk, model): try: diff --git a/apps/common/drf/filters.py b/apps/common/drf/filters.py index 0ee0a0890..e6cb1b261 100644 --- a/apps/common/drf/filters.py +++ b/apps/common/drf/filters.py @@ -25,7 +25,7 @@ __all__ = [ 'IDInFilterBackend', "CustomFilterBackend", "BaseFilterSet", 'IDNotFilterBackend', 'NotOrRelFilterBackend', 'LabelFilterBackend', - 'RewriteOrderingFilter' + 'RewriteOrderingFilter', 'AttrRulesFilterBackend' ] diff --git a/apps/common/serializers/fields.py b/apps/common/serializers/fields.py index dfe9191df..cc9e7f5c7 100644 --- a/apps/common/serializers/fields.py +++ b/apps/common/serializers/fields.py @@ -297,6 +297,7 @@ class JSONManyToManyField(serializers.JSONField): if not data: data = {} try: + data = super().to_internal_value(data) ModelJSONManyToManyField.check_value(data) except ValueError as e: raise serializers.ValidationError(e) diff --git a/apps/i18n/lina/en.json b/apps/i18n/lina/en.json index 5b0e5e82b..6785d2c58 100644 --- a/apps/i18n/lina/en.json +++ b/apps/i18n/lina/en.json @@ -8,6 +8,7 @@ "AccessIP": "IP whitelist", "AccessKey": "Access key", "Account": "Account", + "AccountAmount": "Account amount", "AccountBackup": "Backup accounts", "AccountBackupCreate": "Create account backup", "AccountBackupDetail": "Backup account details", @@ -241,6 +242,7 @@ "CAS": "CAS", "CMPP2": "Cmpp v2.0", "CalculationResults": "Error in cron expression", + "CallRecords": "Call Records", "CanDragSelect": "Select time period by dragging mouse;No selection means all selected", "Cancel": "Cancel", "CancelCollection": "Cancel favorite", @@ -403,6 +405,7 @@ "DateLastLogin": "Last login date", "DateLastMonth": "Last month", "DateLastSync": "Last sync", + "DataLastUsed": "Last used", "DateLastWeek": "Last week", "DateLastYear": "Last year", "DatePasswordLastUpdated": "Last password update date", @@ -1083,6 +1086,7 @@ "ServerAccountKey": "Service account key", "ServerError": "Server error", "ServerTime": "Server time", + "ServiceIntegration": "Service integration", "Session": "Session", "SessionCommands": "Session commands", "SessionConnectTrend": "Session connection trends", diff --git a/apps/i18n/lina/ja.json b/apps/i18n/lina/ja.json index 1c804d4ca..93be81536 100644 --- a/apps/i18n/lina/ja.json +++ b/apps/i18n/lina/ja.json @@ -8,6 +8,7 @@ "AccessIP": "IP ホワイトリスト", "AccessKey": "アクセスキー", "Account": "アカウント情報", + "AccountAmount": "アカウント数", "AccountBackup": "アカウントのバックアップ", "AccountBackupCreate": "アカウントバックアップを作成", "AccountBackupDetail": "アカウントバックアップの詳細", @@ -254,6 +255,7 @@ "CMPP2": "CMPP v2.0", "CTYunPrivate": "天翼プライベートクラウド", "CalculationResults": "cron 式のエラー", + "CallRecords": "つうわきろく", "CanDragSelect": "マウスドラッグで時間帯を選択可能;未選択は全選択と同じです", "Cancel": "キャンセル", "CancelCollection": "お気に入りキャンセル", @@ -418,6 +420,7 @@ "DateLastLogin": "最後にログインした日", "DateLastMonth": "最近一ヶ月", "DateLastSync": "最終同期日", + "DataLastUsed": "さいごしようび", "DateLastWeek": "最新の一週間", "DateLastYear": "最近一年", "DatePasswordLastUpdated": "最終パスワード更新日", @@ -1118,6 +1121,7 @@ "ServerAccountKey": "サービスアカウントキー", "ServerError": "サーバーエラー", "ServerTime": "サーバータイム", + "ServiceIntegration": "サービス統合", "Session": "コンバセーション", "SessionCommands": "セッションアクション", "SessionConnectTrend": "セッションの接続トレンド", diff --git a/apps/i18n/lina/zh.json b/apps/i18n/lina/zh.json index 3da1b5400..89aaa7c82 100644 --- a/apps/i18n/lina/zh.json +++ b/apps/i18n/lina/zh.json @@ -7,6 +7,7 @@ "Accept": "同意", "AccessIP": "IP 白名单", "AccessKey": "访问密钥", + "AccountAmount": "账号数量", "AccountBackup": "账号备份", "AccountBackupCreate": "创建账号备份", "AccountBackupDetail": "账号备份详情", @@ -241,6 +242,7 @@ "CAS": "CAS", "CMPP2": "CMPP v2.0", "CalculationResults": "cron 表达式错误", + "CallRecords": "调用记录", "CanDragSelect": "可拖动鼠标选择时间段;未选择等同全选", "Cancel": "取消", "CancelCollection": "取消收藏", @@ -403,6 +405,7 @@ "DateLastLogin": "最后登录日期", "DateLastMonth": "最近一月", "DateLastSync": "最后同步日期", + "DataLastUsed": "最后使用日期", "DateLastWeek": "最近一周", "DateLastYear": "最近一年", "DatePasswordLastUpdated": "最后更新密码日期", @@ -1086,6 +1089,7 @@ "ServerAccountKey": "服务账号密钥", "ServerError": "服务器错误", "ServerTime": "服务器时间", + "ServiceIntegration": "服务对接", "Session": "会话", "SessionCommands": "会话命令", "SessionConnectTrend": "会话连接趋势", diff --git a/apps/i18n/lina/zh_hant.json b/apps/i18n/lina/zh_hant.json index 4d750a2b1..23d8b19d9 100644 --- a/apps/i18n/lina/zh_hant.json +++ b/apps/i18n/lina/zh_hant.json @@ -8,6 +8,7 @@ "AccessIP": "IP 白名單", "AccessKey": "訪問金鑰", "Account": "雲帳號", + "AccountAmount": "帳號數量", "AccountBackup": "帳號備份", "AccountBackupCreate": "創建帳號備份", "AccountBackupDetail": "賬號備份詳情", @@ -324,7 +325,8 @@ "CASSetting": "CAS 配置", "CMPP2": "CMPP v2.0", "CTYunPrivate": "天翼私有雲", - "CalculationResults": "Cron expression error", + "CalculationResults": "呼叫記錄", + "CallRecords": "調用記錄", "CanDragSelect": "可拖動滑鼠選擇時間段;未選擇等同全選", "Cancel": "取消", "CancelCollection": "取消收藏", @@ -534,6 +536,7 @@ "DateLastMonth": "最近一月", "DateLastRun": "上次運行日期", "DateLastSync": "最後同步日期", + "DataLastUsed": "最後使用日期", "DateLastWeek": "最近一週", "DateLastYear": "最近一年", "DatePasswordLastUpdated": "最後更新密碼日期", @@ -1436,6 +1439,7 @@ "ServerAccountKey": "服務帳號金鑰", "ServerError": "伺服器錯誤", "ServerTime": "伺服器時間", + "ServiceIntegration": "服務對接", "ServiceRatio": "組件負載統計", "Session": "會話", "SessionCommands": "會話指令", diff --git a/apps/jumpserver/settings/libs.py b/apps/jumpserver/settings/libs.py index b8859f7a8..b9385a3f7 100644 --- a/apps/jumpserver/settings/libs.py +++ b/apps/jumpserver/settings/libs.py @@ -32,6 +32,7 @@ REST_FRAMEWORK = { # 'rest_framework.authentication.BasicAuthentication', 'authentication.backends.drf.AccessTokenAuthentication', 'authentication.backends.drf.PrivateTokenAuthentication', + 'authentication.backends.drf.ServiceAuthentication', 'authentication.backends.drf.SignatureAuthentication', 'authentication.backends.drf.SessionAuthentication', ), diff --git a/apps/users/models/user/__init__.py b/apps/users/models/user/__init__.py index 725377ba1..3acdbf701 100644 --- a/apps/users/models/user/__init__.py +++ b/apps/users/models/user/__init__.py @@ -29,7 +29,8 @@ __all__ = [ "User", "UserPasswordHistory", "MFAMixin", - "AuthMixin" + "AuthMixin", + "RoleMixin" ] From 07255eed5af24010dc684f887744553b27d845d6 Mon Sep 17 00:00:00 2001 From: fit2bot <68588906+fit2bot@users.noreply.github.com> Date: Mon, 2 Dec 2024 10:34:41 +0800 Subject: [PATCH 3/3] perf: Change secret dashboard (#14551) Co-authored-by: feng <1304903146@qq.com> --- apps/accounts/api/automations/__init__.py | 1 + .../accounts/api/automations/change_secret.py | 5 +- .../automations/change_secret_dashboard.py | 170 ++++++++++++++++++ apps/accounts/api/automations/push_account.py | 5 +- .../models/automations/change_secret.py | 7 + apps/accounts/urls.py | 1 + 6 files changed, 186 insertions(+), 3 deletions(-) create mode 100644 apps/accounts/api/automations/change_secret_dashboard.py diff --git a/apps/accounts/api/automations/__init__.py b/apps/accounts/api/automations/__init__.py index 61035a089..a81361e35 100644 --- a/apps/accounts/api/automations/__init__.py +++ b/apps/accounts/api/automations/__init__.py @@ -1,6 +1,7 @@ from .backup import * from .base import * from .change_secret import * +from .change_secret_dashboard import * from .check_account import * from .gather_account import * from .push_account import * diff --git a/apps/accounts/api/automations/change_secret.py b/apps/accounts/api/automations/change_secret.py index 05ee515dc..186a49e4e 100644 --- a/apps/accounts/api/automations/change_secret.py +++ b/apps/accounts/api/automations/change_secret.py @@ -54,7 +54,10 @@ class ChangeSecretRecordViewSet(mixins.ListModelMixin, OrgGenericViewSet): return super().get_permissions() def get_queryset(self): - return ChangeSecretRecord.objects.all() + qs = ChangeSecretRecord.get_valid_records() + return qs.objects.filter( + execution__automation__type=self.tp + ) @action(methods=['post'], detail=False, url_path='execute') def execute(self, request, *args, **kwargs): diff --git a/apps/accounts/api/automations/change_secret_dashboard.py b/apps/accounts/api/automations/change_secret_dashboard.py new file mode 100644 index 000000000..c3393f36c --- /dev/null +++ b/apps/accounts/api/automations/change_secret_dashboard.py @@ -0,0 +1,170 @@ +# -*- coding: utf-8 -*- +# +from collections import defaultdict + +from django.http.response import JsonResponse +from django.utils import timezone +from rest_framework.views import APIView + +from accounts.const import AutomationTypes, ChangeSecretRecordStatusChoice +from accounts.models import ChangeSecretAutomation, AutomationExecution, ChangeSecretRecord +from assets.models import Node, Asset +from common.utils import lazyproperty +from common.utils.timezone import local_zero_hour, local_now +from ops.celery import app + +__all__ = ['ChangeSecretDashboardApi'] + + +class ChangeSecretDashboardApi(APIView): + http_method_names = ['get'] + rbac_perms = { + 'GET': 'accounts.view_changesecretautomation', + } + + tp = AutomationTypes.change_secret + task_name = 'accounts.tasks.automation.execute_account_automation_task' + + @lazyproperty + def days(self): + count = self.request.query_params.get('days', 1) + return int(count) + + @property + def days_to_datetime(self): + if self.days == 1: + return local_zero_hour() + return local_now() - timezone.timedelta(days=self.days) + + def get_queryset_date_filter(self, qs, query_field='date_updated'): + return qs.filter(**{f'{query_field}__gte': self.days_to_datetime}) + + @lazyproperty + def date_range_list(self): + return [ + (local_now() - timezone.timedelta(days=i)).date() + for i in range(self.days - 1, -1, -1) + ] + + def filter_by_date_range(self, queryset, field_name): + date_range_bounds = self.days_to_datetime.date(), (local_now() + timezone.timedelta(days=1)).date() + return queryset.filter(**{f'{field_name}__range': date_range_bounds}) + + def calculate_daily_metrics(self, queryset, date_field): + filtered_queryset = self.filter_by_date_range(queryset, date_field) + results = filtered_queryset.values_list(date_field, 'status') + + status_counts = defaultdict(lambda: defaultdict(int)) + + for date_finished, status in results: + date_str = str(date_finished.date()) + if status == ChangeSecretRecordStatusChoice.failed: + status_counts[date_str]['failed'] += 1 + elif status == ChangeSecretRecordStatusChoice.success: + status_counts[date_str]['success'] += 1 + + metrics = defaultdict(list) + for date in self.date_range_list: + date_str = str(date) + for status in ['success', 'failed']: + metrics[status].append(status_counts[date_str].get(status, 0)) + + return metrics + + def get_daily_success_and_failure_metrics(self): + metrics = self.calculate_daily_metrics(self.change_secret_records_queryset, 'date_finished') + return metrics.get('success', []), metrics.get('failed', []) + + @lazyproperty + def change_secrets_queryset(self): + return ChangeSecretAutomation.objects.all() + + @lazyproperty + def change_secret_executions_queryset(self): + return AutomationExecution.objects.filter(automation__type=self.tp) + + @lazyproperty + def change_secret_records_queryset(self): + return ChangeSecretRecord.get_valid_records().filter(execution__automation__type=self.tp) + + def get_change_secret_asset_queryset(self): + qs = self.get_queryset_date_filter(self.change_secrets_queryset) + node_ids = qs.filter(nodes__isnull=False).values_list('nodes', flat=True).distinct() + nodes = Node.objects.filter(id__in=node_ids) + node_asset_ids = Node.get_nodes_all_assets(*nodes).values_list('id', flat=True) + direct_asset_ids = qs.filter(assets__isnull=False).values_list('assets', flat=True).distinct() + asset_ids = set(list(direct_asset_ids) + list(node_asset_ids)) + return Asset.objects.filter(id__in=asset_ids) + + def get_filtered_counts(self, qs, field): + return self.get_queryset_date_filter(qs, field).count() + + @staticmethod + def get_status_counts(records): + pending = ChangeSecretRecordStatusChoice.pending + failed = ChangeSecretRecordStatusChoice.failed + total_ids = {str(i) for i in records.exclude(status=pending).values('execution_id').distinct()} + failed_ids = {str(i) for i in records.filter(status=failed).values('execution_id').distinct()} + total = len(total_ids) + failed = len(total_ids & failed_ids) + return { + 'total_count_change_secret_executions': total, + 'total_count_success_change_secret_executions': total - failed, + 'total_count_failed_change_secret_executions': failed, + } + + def get(self, request, *args, **kwargs): + query_params = self.request.query_params + data = {} + + if query_params.get('total_count_change_secrets'): + data['total_count_change_secrets'] = self.get_filtered_counts( + self.change_secrets_queryset, 'date_updated' + ) + + if query_params.get('total_count_periodic_change_secrets'): + data['total_count_periodic_change_secrets'] = self.get_filtered_counts( + self.change_secrets_queryset.filter(is_periodic=True), 'date_updated' + ) + + if query_params.get('total_count_change_secret_assets'): + data['total_count_change_secret_assets'] = self.get_change_secret_asset_queryset().count() + + if query_params.get('total_count_change_secret_status'): + records = self.get_queryset_date_filter(self.change_secret_records_queryset, 'date_finished') + data.update(self.get_status_counts(records)) + + if query_params.get('total_count_change_secret_status'): + records = self.get_queryset_date_filter(self.change_secret_records_queryset, 'date_finished') + data.update(self.get_status_counts(records)) + + if query_params.get('daily_success_and_failure_metrics'): + success, failed = self.get_daily_success_and_failure_metrics() + data.update({ + 'dates_metrics_date': [date.strftime('%m-%d') for date in self.date_range_list] or ['0'], + 'dates_metrics_total_count_success': success, + 'dates_metrics_total_count_failed': failed, + }) + + if query_params.get('total_count_ongoing_change_secret'): + execution_ids = [] + inspect = app.control.inspect() + active_tasks = inspect.active() + for tasks in active_tasks.values(): + for task in tasks: + _id = task.get('id') + name = task.get('name') + tp = task.kwargs.get('tp') + if name == self.task_name and tp == self.tp: + execution_ids.append(_id) + + snapshots = self.change_secret_executions_queryset.filter( + id__in=execution_ids).values_list('id', 'snapshot') + + asset_ids = {asset for i in snapshots for asset in i.get('assets', [])} + account_ids = {account for i in snapshots for account in i.get('accounts', [])} + data['total_count_ongoing_change_secret'] = len(execution_ids) + data['total_count_ongoing_change_secret_assets'] = len(asset_ids) + data['total_count_ongoing_change_secret_accounts'] = len(account_ids) + + return JsonResponse(data, status=200) diff --git a/apps/accounts/api/automations/push_account.py b/apps/accounts/api/automations/push_account.py index 1fa5c1219..7b27cc7ca 100644 --- a/apps/accounts/api/automations/push_account.py +++ b/apps/accounts/api/automations/push_account.py @@ -45,8 +45,9 @@ class PushAccountRecordViewSet(ChangeSecretRecordViewSet): tp = AutomationTypes.push_account def get_queryset(self): - return ChangeSecretRecord.objects.filter( - execution__automation__type=AutomationTypes.push_account + qs = ChangeSecretRecord.get_valid_records() + return qs.objects.filter( + execution__automation__type=self.tp ) diff --git a/apps/accounts/models/automations/change_secret.py b/apps/accounts/models/automations/change_secret.py index dff4d5cc4..4e42af544 100644 --- a/apps/accounts/models/automations/change_secret.py +++ b/apps/accounts/models/automations/change_secret.py @@ -1,4 +1,5 @@ from django.db import models +from django.db.models import Q from django.utils.translation import gettext_lazy as _ from accounts.const import ( @@ -48,3 +49,9 @@ class ChangeSecretRecord(JMSBaseModel): def __str__(self): return f'{self.account.username}@{self.asset}' + + @staticmethod + def get_valid_records(): + return ChangeSecretRecord.objects.exclude( + Q(execution__isnull=True) | Q(asset__isnull=True) | Q(account__isnull=True) + ) diff --git a/apps/accounts/urls.py b/apps/accounts/urls.py index 352dcfaae..bc901816b 100644 --- a/apps/accounts/urls.py +++ b/apps/accounts/urls.py @@ -49,6 +49,7 @@ urlpatterns = [ path('push-account//nodes/', api.PushAccountNodeAddRemoveApi.as_view(), name='push-account-add-or-remove-node'), path('push-account//assets/', api.PushAccountAssetsListApi.as_view(), name='push-account-assets'), + path('change-secret-dashboard/', api.ChangeSecretDashboardApi.as_view(), name='change-secret-dashboard'), ] urlpatterns += router.urls