From 3f4141ca0b1052a6ae1cb5c3c31dc80783956da9 Mon Sep 17 00:00:00 2001 From: fit2bot <68588906+fit2bot@users.noreply.github.com> Date: Fri, 21 Feb 2025 16:39:57 +0800 Subject: [PATCH] merge: with pam (#14911) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf: change i18n * perf: pam * perf: change translate * perf: add check account * perf: add date field * perf: add account filter * perf: remove some js * perf: add account status action * perf: update pam * perf: 修改 discover account * perf: update filter * perf: update gathered account * perf: 修改账号同步 * perf: squash migrations * perf: update pam * perf: change i18n * perf: update account risk * perf: 更新风险发现 * perf: remove css * perf: Admin connection token * perf: Add a switch to check connectivity after changing the password, and add a custom ssh command for push tasks * perf: Modify account migration files * perf: update pam * perf: remove to check account dir * perf: Admin connection token * perf: update check account * perf: 优化发送结果 * perf: update pam * perf: update bulk update create * perf: prepaire using thread timer for bulk_create_decorator * perf: update bulk create decorator * perf: 优化 playbook manager * perf: 优化收集账号的报表 * perf: Update poetry * perf: Update Dockerfile with new base image tag * fix: Account migrate 0012 file * perf: 修改备份 * perf: update pam * fix: Expand resource_type filter to include raw type * feat: PAM Service (#14552) * feat: PAM Service * perf: import package name --------- Co-authored-by: jiangweidong <1053570670@qq.com> * perf: Change secret dashboard (#14551) Co-authored-by: feng <1304903146@qq.com> * perf: update migrations * perf: 修改支持 pam * perf: Change secret record table dashboard * perf: update status * fix: Automation send report * perf: Change secret report * feat: windows accounts gather * perf: update change status * perf: Account backup * perf: Account backup report * perf: Account migrate * perf: update service to application * perf: update migrations * perf: update logo * feat: oracle accounts gather (#14571) * feat: oracle accounts gather * feat: sqlserver accounts gather * feat: postgresql accounts gather * feat: mysql accounts gather --------- Co-authored-by: wangruidong <940853815@qq.com> * feat: mongodb accounts gather * perf: Change secret * perf: Migrate * perf: Merge conflicting migration files * perf: Change secret * perf: Automation filter org * perf: Account push * perf: Random secret string * perf: Enhance SQL query and update risk handling in accounts * perf: Ticket filter assignee_id * perf: 修改 account remote * perf: 修改一些 adhoc 任务 * perf: Change secret * perf: Remove push account extra api * perf: update status * perf: The entire organization can view activity log * fix: risk field check * perf: add account details api * perf: add demo mode * perf: Delete gather_account * perf: Perfect solution to account version problem * perf: Update status action to handle multiple accounts * perf: Add GatherAccountDetailField and update serializers * perf: Display account history in combination with password change records * perf: Lina translate * fix: Update mysql_filter to handle nested user info * perf: Admin connection token validate_permission account * perf: copy move account * perf: account filter risk * perf: account risk filter * perf: Copy move account failed message * fix: gather account sync account to asset * perf: Pam dashboard * perf: Account dashboard total accounts * perf: Pam dashboard * perf: Change secret filter account secret_reset * perf: 修改 risk filter * perf: pam translate * feat: Check for leaked duplicate passwords. (#14711) * feat: Check for leaked duplicate passwords. * perf: Use SQLite instead of txt as leak password database --------- Co-authored-by: jiangweidong <1053570670@qq.com> Co-authored-by: 老广 * perf: merge with remote * perf: Add risk change_password_add handle * perf: Pam dashboard * perf: check account manager import * perf: 重构扫描 * perf: 修改 db * perf: Gather account manager * perf: update change db lib * perf: dashboard * perf: Account gather * perf: 修改 asset get queryset * perf: automation report * perf: Pam account * perf: Pam dashboard api * perf: risk add account * perf: 修改 risk check * perf: Risk account * perf: update risk add reopen action * perf: add pylintrc * Revert "perf: automation report" This reverts commit 22aee542071638bcefae5a244bcabf76f794d7c3. * perf: check account engine * perf: Perf: Optimism Gather Report Style * Perf: Remove unuser actions * Perf: Perf push account * perf: perf gather account * perf: Automation report * perf: Push account recorder * perf: Push account record * perf: Pam dashboard * perf: perf * perf: update intergration * perf: integrations application detail add account tab page * feat: Custom change password supports configuration of interactive items * perf: Go and Python demo code * perf: Custom secret change * perf: add user filter * perf: translate * perf: Add demo code docs * perf: update some i18n * perf: update some i18n * perf: Add Java, Node, Go, and cURL demo code * perf: Translate * perf: Change secret translate * perf: Translate * perf: update some i18n * perf: translate * perf: Ansible playbook * perf: update some choice * perf: update some choice * perf: update account serializer remote unused code * perf: conflict * perf: update import --------- Co-authored-by: ibuler Co-authored-by: feng <1304903146@qq.com> Co-authored-by: github-actions[bot] Co-authored-by: wangruidong <940853815@qq.com> Co-authored-by: jiangweidong <1053570670@qq.com> Co-authored-by: feng626 <57284900+feng626@users.noreply.github.com> Co-authored-by: zhaojisen <1301338853@qq.com> --- .gitattributes | 2 +- .pylintrc | 2 + apps/accounts/api/account/__init__.py | 2 + apps/accounts/api/account/account.py | 93 +- apps/accounts/api/account/application.py | 84 + apps/accounts/api/account/pam_dashboard.py | 132 + apps/accounts/api/automations/__init__.py | 4 +- apps/accounts/api/automations/backup.py | 41 +- apps/accounts/api/automations/base.py | 10 +- .../accounts/api/automations/change_secret.py | 51 +- .../automations/change_secret_dashboard.py | 169 + .../accounts/api/automations/check_account.py | 153 + .../api/automations/gather_account.py | 123 + .../api/automations/gather_accounts.py | 59 - apps/accounts/api/automations/push_account.py | 24 +- .../automations/backup_account/handlers.py | 112 +- .../automations/backup_account/manager.py | 40 +- apps/accounts/automations/base/manager.py | 161 + .../change_secret/custom/ssh/main.yml | 10 +- .../change_secret/custom/ssh/manifest.yml | 110 +- .../change_secret/database/mongodb/main.yml | 3 +- .../change_secret/database/mysql/main.yml | 3 +- .../change_secret/database/oracle/main.yml | 3 +- .../database/postgresql/main.yml | 3 +- .../change_secret/database/sqlserver/main.yml | 3 +- .../change_secret/host/aix/main.yml | 13 +- .../change_secret/host/posix/main.yml | 13 +- .../change_secret/host/windows/main.yml | 6 +- .../host/windows_rdp_verify/main.yml | 6 +- .../automations/change_secret/manager.py | 272 +- .../__init__.py | 0 .../check_account/add_to_leak_password.py | 78 + .../check_account/leak_passwords.db | 3 + .../automations/check_account/manager.py | 284 ++ apps/accounts/automations/endpoint.py | 10 +- .../automations/gather_account/__init__.py} | 0 .../database/mongodb/main.yml | 4 +- .../database/mongodb/manifest.yml | 0 .../database/mysql/main.yml | 2 +- .../database/mysql/manifest.yml | 0 .../database/oracle/main.yml | 2 +- .../database/oracle/manifest.yml | 0 .../database/postgresql/main.yml | 2 +- .../database/postgresql/manifest.yml | 0 .../database/sqlserver/main.yml | 43 + .../database/sqlserver/manifest.yml | 10 + .../automations/gather_account/filter.py | 248 ++ .../gather_account/host/posix/main.yml | 61 + .../host/posix/manifest.yml | 0 .../gather_account/host/windows/main.yml | 32 + .../host/windows/manifest.yml | 0 .../automations/gather_account/manager.py | 385 ++ .../automations/gather_accounts/filter.py | 75 - .../gather_accounts/host/posix/main.yml | 21 - .../gather_accounts/host/windows/main.yml | 14 - .../automations/gather_accounts/manager.py | 139 - .../push_account/custom/ssh/main.yml | 62 + .../push_account/custom/ssh/manifest.yml | 32 + .../push_account/database/mongodb/main.yml | 3 +- .../push_account/database/mysql/main.yml | 3 +- .../push_account/database/oracle/main.yml | 3 +- .../push_account/database/postgresql/main.yml | 3 +- .../push_account/database/sqlserver/main.yml | 3 +- .../push_account/host/aix/main.yml | 13 +- .../push_account/host/posix/main.yml | 13 +- .../push_account/host/windows/main.yml | 6 +- .../host/windows_rdp_verify/main.yml | 6 +- .../automations/push_account/manager.py | 108 +- .../remove_account/database/mongodb/main.yml | 2 +- .../remove_account/database/mysql/main.yml | 2 +- .../remove_account/database/oracle/main.yml | 2 +- .../database/postgresql/main.yml | 2 +- .../database/sqlserver/main.yml | 2 +- .../automations/remove_account/manager.py | 87 +- .../verify_account/custom/ssh/main.yml | 1 + .../verify_account/database/mongodb/main.yml | 2 +- .../verify_account/database/mysql/main.yml | 2 +- .../verify_account/database/oracle/main.yml | 2 +- .../database/postgresql/main.yml | 2 +- .../database/sqlserver/main.yml | 2 +- apps/accounts/const/account.py | 2 +- apps/accounts/const/automation.py | 25 +- apps/accounts/demos/curl/README.en.md | 41 + apps/accounts/demos/curl/README.ja.md | 42 + apps/accounts/demos/curl/README.zh-hans.md | 40 + apps/accounts/demos/curl/README.zh-hant.md | 39 + apps/accounts/demos/curl/demo.sh | 37 + apps/accounts/demos/go/README.en.md | 45 + apps/accounts/demos/go/README.ja.md | 45 + apps/accounts/demos/go/README.zh-hans.md | 45 + apps/accounts/demos/go/README.zh-hant.md | 153 + apps/accounts/demos/go/demo.go | 119 + apps/accounts/demos/go/jms_pam.go | 162 + apps/accounts/demos/java/Demo.java | 78 + apps/accounts/demos/java/README.en.md | 42 + apps/accounts/demos/java/README.ja.md | 42 + apps/accounts/demos/java/README.zh-hans.md | 41 + apps/accounts/demos/java/README.zh-hant.md | 40 + apps/accounts/demos/node/README.en.md | 43 + apps/accounts/demos/node/README.ja.md | 41 + apps/accounts/demos/node/README.zh-hans.md | 42 + apps/accounts/demos/node/README.zh-hant.md | 41 + apps/accounts/demos/node/demo.js | 56 + apps/accounts/demos/python/README.en.md | 42 + apps/accounts/demos/python/README.ja.md | 42 + apps/accounts/demos/python/README.zh-hans.md | 42 + apps/accounts/demos/python/README.zh-hant.md | 42 + apps/accounts/demos/python/demo.py | 44 + .../accounts/demos/python/jms_pam/__init__.py | 1 + apps/accounts/demos/python/jms_pam/main.py | 148 + apps/accounts/demos/python/setup.py | 22 + apps/accounts/filters.py | 159 +- apps/accounts/migrations/0001_initial.py | 4 +- .../migrations/0002_auto_20220616_0021.py | 20 +- .../migrations/0005_account_secret_reset.py | 244 ++ ...risk_account_accountrisk_asset_and_more.py | 58 + .../migrations/0007_alter_accountrisk_risk.py | 34 + ...k_confirmed_accountrisk_status_and_more.py | 60 + .../0009_alter_accountrisk_comment.py | 58 + ...trisk_details_alter_accountrisk_comment.py | 23 + ...rd_gatheredaccount_date_password_change.py | 18 + ...ckengine_accountcheckautomation_engines.py | 121 + .../0013_checkaccountautomation_recipients.py | 22 + ...014_gatheraccountsautomation_check_risk.py | 18 + .../migrations/0015_alter_accountrisk_risk.py | 35 + .../0016_alter_accountrisk_status_and_more.py | 35 + .../migrations/0017_serviceintegration.py | 41 + ...changesecretrecord_ignore_fail_and_more.py | 23 + .../0019_backupaccountautomation_and_more.py | 133 + ...tion_delete_serviceintegration_and_more.py | 134 + ...e_pushaccountautomation_action_and_more.py | 25 + ...ter_changesecretrecord_options_and_more.py | 24 + .../0023_alter_changesecretrecord_options.py | 17 + ...hangesecretrecord_date_started_and_more.py | 42 + .../0025_alter_accountrisk_risk_and_more.py | 23 + .../migrations/0026_accountrisk_account.py | 25 + .../0027_accountrisk_gathered_account.py | 24 + ...e_checkaccountengine_is_active_and_more.py | 26 + ...ter_changesecretrecord_account_and_more.py | 51 + apps/accounts/models/__init__.py | 1 + apps/accounts/models/account.py | 28 +- apps/accounts/models/application.py | 68 + apps/accounts/models/automations/__init__.py | 1 + .../models/automations/backup_account.py | 119 +- apps/accounts/models/automations/base.py | 37 +- .../models/automations/change_secret.py | 48 +- .../models/automations/check_account.py | 159 + .../models/automations/gather_account.py | 60 +- .../models/automations/push_account.py | 41 +- apps/accounts/models/base.py | 5 +- apps/accounts/models/template.py | 4 + apps/accounts/notifications.py | 30 +- apps/accounts/risk_handlers.py | 174 + apps/accounts/serializers/account/__init__.py | 3 +- apps/accounts/serializers/account/account.py | 14 +- apps/accounts/serializers/account/backup.py | 56 - apps/accounts/serializers/account/base.py | 13 +- .../serializers/account/gathered_account.py | 23 - apps/accounts/serializers/account/service.py | 56 + apps/accounts/serializers/account/template.py | 17 - .../serializers/automations/__init__.py | 4 +- .../serializers/automations/backup.py | 45 + apps/accounts/serializers/automations/base.py | 61 +- .../serializers/automations/change_secret.py | 6 +- .../serializers/automations/check_account.py | 115 + .../serializers/automations/gather_account.py | 91 + .../automations/gather_accounts.py | 27 - .../serializers/automations/push_account.py | 6 +- apps/accounts/signal_handlers.py | 14 +- apps/accounts/tasks/__init__.py | 2 +- apps/accounts/tasks/backup_account.py | 40 - apps/accounts/tasks/gather_accounts.py | 33 - apps/accounts/tasks/remove_account.py | 2 +- apps/accounts/tasks/scan_account.py | 3 + .../accounts/backup_account_report.html | 202 ++ .../accounts/change_secret_report.html | 314 ++ .../accounts/check_account_report.html | 293 ++ .../accounts/gather_account_report.html | 315 ++ .../accounts/push_account_report.html | 314 ++ apps/accounts/urls.py | 15 +- apps/accounts/utils.py | 21 +- apps/assets/api/asset/asset.py | 3 +- apps/assets/api/favorite_asset.py | 2 +- apps/assets/api/tree.py | 3 +- apps/assets/automations/base/manager.py | 468 ++- apps/assets/automations/endpoint.py | 5 +- .../gather_facts/database/mongodb/main.yml | 2 +- .../gather_facts/database/mysql/main.yml | 2 +- .../gather_facts/database/oracle/main.yml | 2 +- .../gather_facts/database/postgresql/main.yml | 2 +- .../automations/ping/custom/ssh/main.yml | 1 + .../ping/database/mongodb/main.yml | 2 +- .../automations/ping/database/mysql/main.yml | 2 +- .../automations/ping/database/oracle/main.yml | 2 +- .../ping/database/postgresql/main.yml | 2 +- .../ping/database/sqlserver/main.yml | 2 +- apps/assets/const/automation.py | 1 + apps/assets/const/device.py | 8 +- apps/assets/const/host.py | 1 + apps/assets/migrations/0001_initial.py | 114 +- .../0006_baseautomation_start_time.py | 23 + ...7_baseautomation_date_last_run_and_more.py | 27 + ...008_automationexecution_result_and_more.py | 23 + .../0009_automationexecution_duration.py | 18 + ...0010_alter_automationexecution_duration.py | 18 + .../migrations/0011_auto_20241204_1516.py | 41 + apps/assets/models/asset/common.py | 4 + apps/assets/models/automations/base.py | 122 +- apps/assets/models/base.py | 4 +- apps/assets/serializers/asset/common.py | 11 +- apps/assets/serializers/automations/base.py | 33 +- apps/assets/serializers/platform.py | 5 +- apps/assets/tasks/common.py | 1 + apps/audits/api.py | 17 +- apps/audits/filters.py | 2 +- .../migrations/0004_serviceaccesslog.py | 26 + .../0005_rename_serviceaccesslog.py | 17 + apps/audits/models.py | 17 +- apps/audits/serializers.py | 16 + apps/audits/urls/api_urls.py | 1 + apps/authentication/api/connection_token.py | 15 +- apps/authentication/backends/drf.py | 37 +- apps/authentication/const.py | 6 + apps/authentication/mfa/face.py | 11 +- .../authentication/migrations/0001_initial.py | 34 +- .../migrations/0004_connectiontoken_type.py | 30 + apps/authentication/models/access_key.py | 6 +- .../authentication/models/connection_token.py | 31 + .../templates/authentication/login.html | 8 + apps/authentication/urls/api_urls.py | 1 + apps/common/api/generic.py | 2 +- apps/common/auth/signature.py | 9 +- apps/common/const/choices.py | 10 +- apps/common/db/fields.py | 1 + apps/common/db/utils.py | 34 +- apps/common/decorators.py | 90 +- apps/common/drf/filters.py | 185 +- apps/common/serializers/fields.py | 1 + apps/common/utils/common.py | 5 + apps/common/utils/strings.py | 35 + apps/common/utils/timezone.py | 20 + apps/i18n/core/en/LC_MESSAGES/django.po | 3205 +++++++++-------- apps/i18n/core/ja/LC_MESSAGES/django.po | 609 +++- apps/i18n/core/zh/LC_MESSAGES/django.po | 1272 ++++--- apps/i18n/core/zh_Hant/LC_MESSAGES/django.po | 2017 +++++++---- apps/i18n/lina/en.json | 116 +- apps/i18n/lina/ja.json | 21 +- apps/i18n/lina/zh.json | 39 +- apps/i18n/lina/zh_hant.json | 25 +- .../rewriting/storage/permissions.py | 3 +- apps/jumpserver/settings/libs.py | 3 + apps/libs/ansible/modules/custom_command.py | 44 +- apps/libs/ansible/modules/mongodb_ping.py | 2 +- apps/libs/ansible/modules/oracle_info.py | 22 + apps/libs/ansible/modules/rdp_ping.py | 88 +- apps/libs/ansible/modules/ssh_ping.py | 55 +- .../ansible/modules_utils/custom_common.py | 207 -- .../ansible/modules_utils/remote_client.py | 277 ++ apps/ops/ansible/cleaner.py | 2 +- apps/ops/ansible/inventory.py | 2 + apps/ops/celery/utils.py | 8 +- ...historicaljob_start_time_job_start_time.py | 33 + ...historicaljob_crontab_alter_job_crontab.py | 27 + apps/ops/mixin.py | 81 +- apps/ops/serializers/job.py | 5 +- apps/ops/templates/ops/celery_task_log.html | 3 +- apps/orgs/mixins/api.py | 2 +- apps/perms/filters.py | 29 +- apps/perms/utils/asset_perm.py | 10 +- apps/rbac/permissions.py | 2 +- apps/settings/api/__init__.py | 2 +- apps/settings/api/i18n.py | 2 +- .../templates/ldap/_msg_import_ldap_user.html | 2 +- .../css/patterns/congruent_pentagon.png | Bin 28934 -> 0 bytes .../css/patterns/header-profile-skin-1.png | Bin 26278 -> 0 bytes .../css/patterns/header-profile-skin-2.png | Bin 28211 -> 0 bytes .../css/patterns/header-profile-skin-3.png | Bin 33032 -> 0 bytes apps/static/css/patterns/header-profile.png | Bin 5877 -> 0 bytes apps/static/css/patterns/otis_redding.png | Bin 10849 -> 0 bytes apps/static/css/patterns/shattered.png | Bin 137733 -> 0 bytes apps/static/css/patterns/triangular.png | Bin 210 -> 0 bytes .../awesome-bootstrap-checkbox.css | 251 -- .../css/plugins/codemirror/ambiance.css | 77 - .../css/plugins/codemirror/codemirror.css | 309 -- .../css/plugins/cropper/cropper.min.css | 9 - .../css/plugins/datatables/datatables.min.css | 39 - .../plugins/datatables/datatables.min.css.bak | 39 - .../css/plugins/datepicker/datepicker3.css | 789 ---- .../daterangepicker/daterangepicker.css | 388 -- apps/static/css/plugins/dropzone/basic.css | 155 - apps/static/css/plugins/dropzone/dropzone.css | 410 --- .../css/plugins/footable/fonts/footable.eot | Bin 4824 -> 0 bytes .../css/plugins/footable/fonts/footable.svg | 78 - .../css/plugins/footable/fonts/footable.ttf | Bin 4656 -> 0 bytes .../css/plugins/footable/fonts/footable.woff | Bin 4016 -> 0 bytes .../css/plugins/footable/footable.core.css | 179 - .../css/plugins/fullcalendar/fullcalendar.css | 977 ----- .../fullcalendar/fullcalendar.print.css | 202 -- apps/static/css/plugins/iCheck/custom.css | 59 - apps/static/css/plugins/iCheck/green.png | Bin 5064 -> 0 bytes apps/static/css/plugins/iCheck/green@2x.png | Bin 7708 -> 0 bytes .../alpha-horizontal.png | Bin 3635 -> 0 bytes .../images/bootstrap-colorpicker/alpha.png | Bin 3271 -> 0 bytes .../bootstrap-colorpicker/hue-horizontal.png | Bin 2837 -> 0 bytes .../images/bootstrap-colorpicker/hue.png | Bin 2972 -> 0 bytes .../bootstrap-colorpicker/saturation.png | Bin 8817 -> 0 bytes apps/static/css/plugins/images/sort.png | Bin 1060 -> 0 bytes apps/static/css/plugins/images/sort_asc.png | Bin 1022 -> 0 bytes apps/static/css/plugins/images/sort_desc.png | Bin 1017 -> 0 bytes .../css/plugins/images/sprite-skin-flat.png | Bin 3376 -> 0 bytes .../css/plugins/images/sprite-skin-flat2.png | Bin 783 -> 0 bytes .../css/plugins/images/sprite-skin-nice.png | Bin 1022 -> 0 bytes .../css/plugins/images/sprite-skin-simple.png | Bin 385 -> 0 bytes apps/static/css/plugins/images/spritemap.png | Bin 10208 -> 0 bytes .../css/plugins/images/spritemap@2x.png | Bin 35675 -> 0 bytes apps/static/css/plugins/jstree/32px.png | Bin 3121 -> 0 bytes apps/static/css/plugins/jstree/39px.png | Bin 5593 -> 0 bytes apps/static/css/plugins/jstree/40px.png | Bin 1880 -> 0 bytes apps/static/css/plugins/jstree/style.css | 1050 ------ apps/static/css/plugins/jstree/style.min.css | 1 - apps/static/css/plugins/jstree/throbber.gif | Bin 1720 -> 0 bytes .../css/plugins/ladda/ladda-themeless.min.css | 7 - apps/static/css/plugins/ladda/ladda.min.css | 9 - .../css/plugins/layer/default/icon-ext.png | Bin 5911 -> 0 bytes .../static/css/plugins/layer/default/icon.png | Bin 11493 -> 0 bytes .../css/plugins/layer/default/loading-0.gif | Bin 5793 -> 0 bytes .../css/plugins/layer/default/loading-1.gif | Bin 701 -> 0 bytes .../css/plugins/layer/default/loading-2.gif | Bin 1787 -> 0 bytes apps/static/css/plugins/layer/layer.css | 7 - .../static/css/plugins/steps/jquery.steps.css | 379 -- .../css/plugins/sweetalert/sweetalert.css | 715 ---- .../images/loading.gif | Bin .../images/validator_default.png | Bin .../images/validator_simple.png | Bin .../jquery.validator.css | 0 .../plugins/ztree/awesomeStyle/awesome.css | 412 --- .../plugins/ztree/awesomeStyle/awesome.less | 154 - .../css/plugins/ztree/awesomeStyle/fa.less | 480 --- .../ztree/awesomeStyle/img/loading.gif | Bin 381 -> 0 bytes apps/static/css/plugins/ztree/demo.css | 33 - .../ztree/metroStyle/img/line_conn.png | Bin 933 -> 0 bytes .../plugins/ztree/metroStyle/img/loading.gif | Bin 381 -> 0 bytes .../plugins/ztree/metroStyle/img/metro.gif | Bin 4679 -> 0 bytes .../plugins/ztree/metroStyle/img/metro.png | Bin 5283 -> 0 bytes .../plugins/ztree/metroStyle/metroStyle.css | 96 - .../ztree/ztreestyle/img/diy/1_close.png | Bin 601 -> 0 bytes .../ztree/ztreestyle/img/diy/1_open.png | Bin 580 -> 0 bytes .../plugins/ztree/ztreestyle/img/diy/2.png | Bin 570 -> 0 bytes .../plugins/ztree/ztreestyle/img/diy/3.png | Bin 762 -> 0 bytes .../plugins/ztree/ztreestyle/img/diy/4.png | Bin 399 -> 0 bytes .../plugins/ztree/ztreestyle/img/diy/5.png | Bin 710 -> 0 bytes .../plugins/ztree/ztreestyle/img/diy/6.png | Bin 432 -> 0 bytes .../plugins/ztree/ztreestyle/img/diy/7.png | Bin 534 -> 0 bytes .../plugins/ztree/ztreestyle/img/diy/8.png | Bin 529 -> 0 bytes .../plugins/ztree/ztreestyle/img/diy/9.png | Bin 467 -> 0 bytes .../ztree/ztreestyle/img/line_conn.gif | Bin 45 -> 0 bytes .../plugins/ztree/ztreestyle/img/loading.gif | Bin 381 -> 0 bytes .../ztree/ztreestyle/img/zTreeStandard.gif | Bin 5564 -> 0 bytes .../ztree/ztreestyle/img/zTreeStandard.png | Bin 11173 -> 0 bytes .../plugins/ztree/ztreestyle/ztreestyle.css | 97 - apps/static/img/JumpServer_white_logo.svg | 1 + apps/static/js/inspinia.js | 77 +- apps/static/js/plugins/cropper/cropper.min.js | 9 - .../js/plugins/datatables/datatables.min.js | 535 --- .../js/plugins/datatables/i18n/English.lang | 30 - .../js/plugins/datatables/i18n/zh-hans.json | 24 - .../datepicker/bootstrap-datepicker.js | 1671 --------- .../bootstrap-datepicker.zh-CN.min.js | 1 - apps/static/js/plugins/echarts/chart/bar.js | 1 - apps/static/js/plugins/echarts/chart/chord.js | 1 - .../js/plugins/echarts/chart/eventRiver.js | 1 - apps/static/js/plugins/echarts/chart/force.js | 1 - .../static/js/plugins/echarts/chart/funnel.js | 1 - apps/static/js/plugins/echarts/chart/gauge.js | 1 - .../js/plugins/echarts/chart/heatmap.js | 1 - apps/static/js/plugins/echarts/chart/k.js | 1 - apps/static/js/plugins/echarts/chart/line.js | 1 - apps/static/js/plugins/echarts/chart/map.js | 13 - apps/static/js/plugins/echarts/chart/pie.js | 1 - apps/static/js/plugins/echarts/chart/radar.js | 1 - .../js/plugins/echarts/chart/scatter.js | 1 - apps/static/js/plugins/echarts/chart/tree.js | 1 - .../js/plugins/echarts/chart/treemap.js | 1 - apps/static/js/plugins/echarts/chart/venn.js | 1 - .../js/plugins/echarts/chart/wordCloud.js | 2 - apps/static/js/plugins/echarts/echarts-all.js | 35 - apps/static/js/plugins/echarts/echarts.js | 20 - apps/static/js/plugins/echarts/echarts.min.js | 22 - .../js/plugins/ztree/jquery-1.4.4.min.js | 167 - .../js/plugins/ztree/jquery.ztree.all.min.js | 167 - .../plugins/ztree/jquery.ztree.exhide.min.js | 23 - apps/templates/_head_css_js.html | 5 +- apps/users/api/group.py | 2 +- apps/users/api/user.py | 2 +- apps/users/filters.py | 62 +- apps/users/models/user/__init__.py | 3 +- apps/users/utils.py | 6 + poetry.lock | 84 +- pyproject.toml | 1 + 399 files changed, 14655 insertions(+), 15063 deletions(-) create mode 100644 .pylintrc create mode 100644 apps/accounts/api/account/application.py create mode 100644 apps/accounts/api/account/pam_dashboard.py create mode 100644 apps/accounts/api/automations/change_secret_dashboard.py create mode 100644 apps/accounts/api/automations/check_account.py create mode 100644 apps/accounts/api/automations/gather_account.py delete mode 100644 apps/accounts/api/automations/gather_accounts.py rename apps/accounts/automations/{gather_accounts => check_account}/__init__.py (100%) create mode 100644 apps/accounts/automations/check_account/add_to_leak_password.py create mode 100644 apps/accounts/automations/check_account/leak_passwords.db create mode 100644 apps/accounts/automations/check_account/manager.py rename apps/{static/css/plugins/ztree/awesomeStyle/fa.css => accounts/automations/gather_account/__init__.py} (100%) rename apps/accounts/automations/{gather_accounts => gather_account}/database/mongodb/main.yml (90%) rename apps/accounts/automations/{gather_accounts => gather_account}/database/mongodb/manifest.yml (100%) rename apps/accounts/automations/{gather_accounts => gather_account}/database/mysql/main.yml (94%) rename apps/accounts/automations/{gather_accounts => gather_account}/database/mysql/manifest.yml (100%) rename apps/accounts/automations/{gather_accounts => gather_account}/database/oracle/main.yml (89%) rename apps/accounts/automations/{gather_accounts => gather_account}/database/oracle/manifest.yml (100%) rename apps/accounts/automations/{gather_accounts => gather_account}/database/postgresql/main.yml (94%) rename apps/accounts/automations/{gather_accounts => gather_account}/database/postgresql/manifest.yml (100%) create mode 100644 apps/accounts/automations/gather_account/database/sqlserver/main.yml create mode 100644 apps/accounts/automations/gather_account/database/sqlserver/manifest.yml create mode 100644 apps/accounts/automations/gather_account/filter.py create mode 100644 apps/accounts/automations/gather_account/host/posix/main.yml rename apps/accounts/automations/{gather_accounts => gather_account}/host/posix/manifest.yml (100%) create mode 100644 apps/accounts/automations/gather_account/host/windows/main.yml rename apps/accounts/automations/{gather_accounts => gather_account}/host/windows/manifest.yml (100%) create mode 100644 apps/accounts/automations/gather_account/manager.py delete mode 100644 apps/accounts/automations/gather_accounts/filter.py delete mode 100644 apps/accounts/automations/gather_accounts/host/posix/main.yml delete mode 100644 apps/accounts/automations/gather_accounts/host/windows/main.yml delete mode 100644 apps/accounts/automations/gather_accounts/manager.py create mode 100644 apps/accounts/automations/push_account/custom/ssh/main.yml create mode 100644 apps/accounts/automations/push_account/custom/ssh/manifest.yml create mode 100644 apps/accounts/demos/curl/README.en.md create mode 100644 apps/accounts/demos/curl/README.ja.md create mode 100644 apps/accounts/demos/curl/README.zh-hans.md create mode 100644 apps/accounts/demos/curl/README.zh-hant.md create mode 100644 apps/accounts/demos/curl/demo.sh 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/demo.go create mode 100644 apps/accounts/demos/go/jms_pam.go create mode 100644 apps/accounts/demos/java/Demo.java create mode 100644 apps/accounts/demos/java/README.en.md create mode 100644 apps/accounts/demos/java/README.ja.md create mode 100644 apps/accounts/demos/java/README.zh-hans.md create mode 100644 apps/accounts/demos/java/README.zh-hant.md create mode 100644 apps/accounts/demos/node/README.en.md create mode 100644 apps/accounts/demos/node/README.ja.md create mode 100644 apps/accounts/demos/node/README.zh-hans.md create mode 100644 apps/accounts/demos/node/README.zh-hant.md create mode 100644 apps/accounts/demos/node/demo.js 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/demo.py 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/0005_account_secret_reset.py create mode 100644 apps/accounts/migrations/0006_remove_accountrisk_account_accountrisk_asset_and_more.py create mode 100644 apps/accounts/migrations/0007_alter_accountrisk_risk.py create mode 100644 apps/accounts/migrations/0008_remove_accountrisk_confirmed_accountrisk_status_and_more.py create mode 100644 apps/accounts/migrations/0009_alter_accountrisk_comment.py create mode 100644 apps/accounts/migrations/0010_accountrisk_details_alter_accountrisk_comment.py create mode 100644 apps/accounts/migrations/0011_rename_date_change_password_gatheredaccount_date_password_change.py create mode 100644 apps/accounts/migrations/0012_accountcheckengine_accountcheckautomation_engines.py create mode 100644 apps/accounts/migrations/0013_checkaccountautomation_recipients.py create mode 100644 apps/accounts/migrations/0014_gatheraccountsautomation_check_risk.py create mode 100644 apps/accounts/migrations/0015_alter_accountrisk_risk.py create mode 100644 apps/accounts/migrations/0016_alter_accountrisk_status_and_more.py create mode 100644 apps/accounts/migrations/0017_serviceintegration.py create mode 100644 apps/accounts/migrations/0018_changesecretrecord_ignore_fail_and_more.py create mode 100644 apps/accounts/migrations/0019_backupaccountautomation_and_more.py create mode 100644 apps/accounts/migrations/0020_integrationapplication_delete_serviceintegration_and_more.py create mode 100644 apps/accounts/migrations/0021_remove_pushaccountautomation_action_and_more.py create mode 100644 apps/accounts/migrations/0022_alter_changesecretrecord_options_and_more.py create mode 100644 apps/accounts/migrations/0023_alter_changesecretrecord_options.py create mode 100644 apps/accounts/migrations/0024_remove_changesecretrecord_date_started_and_more.py create mode 100644 apps/accounts/migrations/0025_alter_accountrisk_risk_and_more.py create mode 100644 apps/accounts/migrations/0026_accountrisk_account.py create mode 100644 apps/accounts/migrations/0027_accountrisk_gathered_account.py create mode 100644 apps/accounts/migrations/0028_remove_checkaccountengine_is_active_and_more.py create mode 100644 apps/accounts/migrations/0029_alter_changesecretrecord_account_and_more.py create mode 100644 apps/accounts/models/application.py create mode 100644 apps/accounts/models/automations/check_account.py create mode 100644 apps/accounts/risk_handlers.py delete mode 100644 apps/accounts/serializers/account/backup.py delete mode 100644 apps/accounts/serializers/account/gathered_account.py create mode 100644 apps/accounts/serializers/account/service.py create mode 100644 apps/accounts/serializers/automations/backup.py create mode 100644 apps/accounts/serializers/automations/check_account.py create mode 100644 apps/accounts/serializers/automations/gather_account.py delete mode 100644 apps/accounts/serializers/automations/gather_accounts.py create mode 100644 apps/accounts/tasks/scan_account.py create mode 100644 apps/accounts/templates/accounts/backup_account_report.html create mode 100644 apps/accounts/templates/accounts/change_secret_report.html create mode 100644 apps/accounts/templates/accounts/check_account_report.html create mode 100644 apps/accounts/templates/accounts/gather_account_report.html create mode 100644 apps/accounts/templates/accounts/push_account_report.html create mode 100644 apps/assets/migrations/0006_baseautomation_start_time.py create mode 100644 apps/assets/migrations/0007_baseautomation_date_last_run_and_more.py create mode 100644 apps/assets/migrations/0008_automationexecution_result_and_more.py create mode 100644 apps/assets/migrations/0009_automationexecution_duration.py create mode 100644 apps/assets/migrations/0010_alter_automationexecution_duration.py create mode 100644 apps/assets/migrations/0011_auto_20241204_1516.py create mode 100644 apps/audits/migrations/0004_serviceaccesslog.py create mode 100644 apps/audits/migrations/0005_rename_serviceaccesslog.py create mode 100644 apps/authentication/migrations/0004_connectiontoken_type.py delete mode 100644 apps/libs/ansible/modules_utils/custom_common.py create mode 100644 apps/libs/ansible/modules_utils/remote_client.py create mode 100644 apps/ops/migrations/0004_historicaljob_start_time_job_start_time.py create mode 100644 apps/ops/migrations/0005_alter_historicaljob_crontab_alter_job_crontab.py delete mode 100644 apps/static/css/patterns/congruent_pentagon.png delete mode 100644 apps/static/css/patterns/header-profile-skin-1.png delete mode 100644 apps/static/css/patterns/header-profile-skin-2.png delete mode 100644 apps/static/css/patterns/header-profile-skin-3.png delete mode 100644 apps/static/css/patterns/header-profile.png delete mode 100644 apps/static/css/patterns/otis_redding.png delete mode 100644 apps/static/css/patterns/shattered.png delete mode 100644 apps/static/css/patterns/triangular.png delete mode 100644 apps/static/css/plugins/awesome-bootstrap-checkbox/awesome-bootstrap-checkbox.css delete mode 100755 apps/static/css/plugins/codemirror/ambiance.css delete mode 100755 apps/static/css/plugins/codemirror/codemirror.css delete mode 100755 apps/static/css/plugins/cropper/cropper.min.css delete mode 100644 apps/static/css/plugins/datatables/datatables.min.css delete mode 100644 apps/static/css/plugins/datatables/datatables.min.css.bak delete mode 100644 apps/static/css/plugins/datepicker/datepicker3.css delete mode 100644 apps/static/css/plugins/daterangepicker/daterangepicker.css delete mode 100644 apps/static/css/plugins/dropzone/basic.css delete mode 100644 apps/static/css/plugins/dropzone/dropzone.css delete mode 100644 apps/static/css/plugins/footable/fonts/footable.eot delete mode 100644 apps/static/css/plugins/footable/fonts/footable.svg delete mode 100644 apps/static/css/plugins/footable/fonts/footable.ttf delete mode 100644 apps/static/css/plugins/footable/fonts/footable.woff delete mode 100644 apps/static/css/plugins/footable/footable.core.css delete mode 100644 apps/static/css/plugins/fullcalendar/fullcalendar.css delete mode 100644 apps/static/css/plugins/fullcalendar/fullcalendar.print.css delete mode 100644 apps/static/css/plugins/iCheck/custom.css delete mode 100644 apps/static/css/plugins/iCheck/green.png delete mode 100644 apps/static/css/plugins/iCheck/green@2x.png delete mode 100644 apps/static/css/plugins/images/bootstrap-colorpicker/alpha-horizontal.png delete mode 100644 apps/static/css/plugins/images/bootstrap-colorpicker/alpha.png delete mode 100644 apps/static/css/plugins/images/bootstrap-colorpicker/hue-horizontal.png delete mode 100644 apps/static/css/plugins/images/bootstrap-colorpicker/hue.png delete mode 100644 apps/static/css/plugins/images/bootstrap-colorpicker/saturation.png delete mode 100644 apps/static/css/plugins/images/sort.png delete mode 100644 apps/static/css/plugins/images/sort_asc.png delete mode 100644 apps/static/css/plugins/images/sort_desc.png delete mode 100644 apps/static/css/plugins/images/sprite-skin-flat.png delete mode 100644 apps/static/css/plugins/images/sprite-skin-flat2.png delete mode 100644 apps/static/css/plugins/images/sprite-skin-nice.png delete mode 100644 apps/static/css/plugins/images/sprite-skin-simple.png delete mode 100644 apps/static/css/plugins/images/spritemap.png delete mode 100644 apps/static/css/plugins/images/spritemap@2x.png delete mode 100755 apps/static/css/plugins/jstree/32px.png delete mode 100755 apps/static/css/plugins/jstree/39px.png delete mode 100755 apps/static/css/plugins/jstree/40px.png delete mode 100755 apps/static/css/plugins/jstree/style.css delete mode 100755 apps/static/css/plugins/jstree/style.min.css delete mode 100755 apps/static/css/plugins/jstree/throbber.gif delete mode 100755 apps/static/css/plugins/ladda/ladda-themeless.min.css delete mode 100755 apps/static/css/plugins/ladda/ladda.min.css delete mode 100644 apps/static/css/plugins/layer/default/icon-ext.png delete mode 100644 apps/static/css/plugins/layer/default/icon.png delete mode 100644 apps/static/css/plugins/layer/default/loading-0.gif delete mode 100644 apps/static/css/plugins/layer/default/loading-1.gif delete mode 100644 apps/static/css/plugins/layer/default/loading-2.gif delete mode 100644 apps/static/css/plugins/layer/layer.css delete mode 100644 apps/static/css/plugins/steps/jquery.steps.css delete mode 100644 apps/static/css/plugins/sweetalert/sweetalert.css rename apps/static/css/plugins/{vaildator => validator}/images/loading.gif (100%) rename apps/static/css/plugins/{vaildator => validator}/images/validator_default.png (100%) rename apps/static/css/plugins/{vaildator => validator}/images/validator_simple.png (100%) rename apps/static/css/plugins/{vaildator => validator}/jquery.validator.css (100%) delete mode 100644 apps/static/css/plugins/ztree/awesomeStyle/awesome.css delete mode 100644 apps/static/css/plugins/ztree/awesomeStyle/awesome.less delete mode 100644 apps/static/css/plugins/ztree/awesomeStyle/fa.less delete mode 100644 apps/static/css/plugins/ztree/awesomeStyle/img/loading.gif delete mode 100644 apps/static/css/plugins/ztree/demo.css delete mode 100644 apps/static/css/plugins/ztree/metroStyle/img/line_conn.png delete mode 100644 apps/static/css/plugins/ztree/metroStyle/img/loading.gif delete mode 100644 apps/static/css/plugins/ztree/metroStyle/img/metro.gif delete mode 100644 apps/static/css/plugins/ztree/metroStyle/img/metro.png delete mode 100644 apps/static/css/plugins/ztree/metroStyle/metroStyle.css delete mode 100644 apps/static/css/plugins/ztree/ztreestyle/img/diy/1_close.png delete mode 100644 apps/static/css/plugins/ztree/ztreestyle/img/diy/1_open.png delete mode 100644 apps/static/css/plugins/ztree/ztreestyle/img/diy/2.png delete mode 100644 apps/static/css/plugins/ztree/ztreestyle/img/diy/3.png delete mode 100644 apps/static/css/plugins/ztree/ztreestyle/img/diy/4.png delete mode 100644 apps/static/css/plugins/ztree/ztreestyle/img/diy/5.png delete mode 100644 apps/static/css/plugins/ztree/ztreestyle/img/diy/6.png delete mode 100644 apps/static/css/plugins/ztree/ztreestyle/img/diy/7.png delete mode 100644 apps/static/css/plugins/ztree/ztreestyle/img/diy/8.png delete mode 100644 apps/static/css/plugins/ztree/ztreestyle/img/diy/9.png delete mode 100644 apps/static/css/plugins/ztree/ztreestyle/img/line_conn.gif delete mode 100644 apps/static/css/plugins/ztree/ztreestyle/img/loading.gif delete mode 100644 apps/static/css/plugins/ztree/ztreestyle/img/zTreeStandard.gif delete mode 100644 apps/static/css/plugins/ztree/ztreestyle/img/zTreeStandard.png delete mode 100644 apps/static/css/plugins/ztree/ztreestyle/ztreestyle.css create mode 100755 apps/static/img/JumpServer_white_logo.svg delete mode 100644 apps/static/js/plugins/cropper/cropper.min.js delete mode 100644 apps/static/js/plugins/datatables/datatables.min.js delete mode 100644 apps/static/js/plugins/datatables/i18n/English.lang delete mode 100644 apps/static/js/plugins/datatables/i18n/zh-hans.json delete mode 100644 apps/static/js/plugins/datepicker/bootstrap-datepicker.js delete mode 100644 apps/static/js/plugins/datepicker/bootstrap-datepicker.zh-CN.min.js delete mode 100644 apps/static/js/plugins/echarts/chart/bar.js delete mode 100644 apps/static/js/plugins/echarts/chart/chord.js delete mode 100644 apps/static/js/plugins/echarts/chart/eventRiver.js delete mode 100644 apps/static/js/plugins/echarts/chart/force.js delete mode 100644 apps/static/js/plugins/echarts/chart/funnel.js delete mode 100644 apps/static/js/plugins/echarts/chart/gauge.js delete mode 100644 apps/static/js/plugins/echarts/chart/heatmap.js delete mode 100644 apps/static/js/plugins/echarts/chart/k.js delete mode 100644 apps/static/js/plugins/echarts/chart/line.js delete mode 100644 apps/static/js/plugins/echarts/chart/map.js delete mode 100644 apps/static/js/plugins/echarts/chart/pie.js delete mode 100644 apps/static/js/plugins/echarts/chart/radar.js delete mode 100644 apps/static/js/plugins/echarts/chart/scatter.js delete mode 100644 apps/static/js/plugins/echarts/chart/tree.js delete mode 100644 apps/static/js/plugins/echarts/chart/treemap.js delete mode 100644 apps/static/js/plugins/echarts/chart/venn.js delete mode 100644 apps/static/js/plugins/echarts/chart/wordCloud.js delete mode 100644 apps/static/js/plugins/echarts/echarts-all.js delete mode 100644 apps/static/js/plugins/echarts/echarts.js delete mode 100644 apps/static/js/plugins/echarts/echarts.min.js delete mode 100644 apps/static/js/plugins/ztree/jquery-1.4.4.min.js delete mode 100644 apps/static/js/plugins/ztree/jquery.ztree.all.min.js delete mode 100644 apps/static/js/plugins/ztree/jquery.ztree.exhide.min.js diff --git a/.gitattributes b/.gitattributes index 51d79b9d1..da82b5fac 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,4 @@ *.mmdb filter=lfs diff=lfs merge=lfs -text *.mo filter=lfs diff=lfs merge=lfs -text *.ipdb filter=lfs diff=lfs merge=lfs -text - +leak_passwords.db filter=lfs diff=lfs merge=lfs -text diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 000000000..e03daa5a4 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,2 @@ +[MESSAGES CONTROL] +disable=missing-module-docstring,missing-class-docstring,missing-function-docstring,too-many-ancestors diff --git a/apps/accounts/api/account/__init__.py b/apps/accounts/api/account/__init__.py index 7f90c23c7..c2221a379 100644 --- a/apps/accounts/api/account/__init__.py +++ b/apps/accounts/api/account/__init__.py @@ -1,4 +1,6 @@ from .account import * +from .application import * +from .pam_dashboard import * from .task import * from .template import * from .virtual import * diff --git a/apps/accounts/api/account/account.py b/apps/accounts/api/account/account.py index 8cfeeec8c..24eb47b27 100644 --- a/apps/accounts/api/account/account.py +++ b/apps/accounts/api/account/account.py @@ -1,20 +1,27 @@ +from django.db import transaction from django.shortcuts import get_object_or_404 +from django.utils.translation import gettext_lazy as _ from rest_framework.decorators import action from rest_framework.generics import ListAPIView, CreateAPIView from rest_framework.response import Response from rest_framework.status import HTTP_200_OK from accounts import serializers +from accounts.const import ChangeSecretRecordStatusChoice from accounts.filters import AccountFilterSet from accounts.mixins import AccountRecordViewLogMixin -from accounts.models import Account +from accounts.models import Account, ChangeSecretRecord from assets.models import Asset, Node from authentication.permissions import UserConfirmation, ConfirmType from common.api.mixin import ExtraFilterFieldsMixin +from common.drf.filters import AttrRulesFilterBackend from common.permissions import IsValidUser +from common.utils import lazyproperty, get_logger from orgs.mixins.api import OrgBulkModelViewSet from rbac.permissions import RBACPermission +logger = get_logger(__file__) + __all__ = [ 'AccountViewSet', 'AccountSecretsViewSet', 'AccountHistoriesSecretAPI', 'AssetAccountBulkCreateApi', @@ -24,6 +31,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, @@ -33,6 +41,8 @@ class AccountViewSet(OrgBulkModelViewSet): 'partial_update': ['accounts.change_account'], 'su_from_accounts': 'accounts.view_account', 'clear_secret': 'accounts.change_account', + 'move_to_assets': 'accounts.create_account', + 'copy_to_assets': 'accounts.create_account', } export_as_zip = True @@ -86,6 +96,45 @@ class AccountViewSet(OrgBulkModelViewSet): self.model.objects.filter(id__in=account_ids).update(secret=None) return Response(status=HTTP_200_OK) + def _copy_or_move_to_assets(self, request, move=False): + account = self.get_object() + asset_ids = request.data.get('assets', []) + assets = Asset.objects.filter(id__in=asset_ids) + field_names = [ + 'name', 'username', 'secret_type', 'secret', + 'privileged', 'is_active', 'source', 'source_id', 'comment' + ] + account_data = {field: getattr(account, field) for field in field_names} + + creation_results = {} + success_count = 0 + + for asset in assets: + account_data['asset'] = asset + creation_results[asset] = {'state': 'created'} + try: + with transaction.atomic(): + self.model.objects.create(**account_data) + success_count += 1 + except Exception as e: + logger.debug(f'{ "Move" if move else "Copy" } to assets error: {e}') + creation_results[asset] = {'error': _('Account already exists'), 'state': 'error'} + + results = [{'asset': str(asset), **res} for asset, res in creation_results.items()] + + if move and success_count > 0: + account.delete() + + return Response(results, status=HTTP_200_OK) + + @action(methods=['post'], detail=True, url_path='move-to-assets') + def move_to_assets(self, request, *args, **kwargs): + return self._copy_or_move_to_assets(request, move=True) + + @action(methods=['post'], detail=True, url_path='copy-to-assets') + def copy_to_assets(self, request, *args, **kwargs): + return self._copy_or_move_to_assets(request, move=False) + class AccountSecretsViewSet(AccountRecordViewLogMixin, AccountViewSet): """ @@ -125,17 +174,31 @@ class AccountHistoriesSecretAPI(ExtraFilterFieldsMixin, AccountRecordViewLogMixi 'GET': 'accounts.view_accountsecret', } - def get_object(self): + @lazyproperty + def account(self) -> Account: return get_object_or_404(Account, pk=self.kwargs.get('pk')) + def get_object(self): + return self.account + + @lazyproperty + def latest_history(self): + return self.account.history.first() + + @property + def latest_change_secret_record(self) -> ChangeSecretRecord: + return self.account.changesecretrecords.filter( + status=ChangeSecretRecordStatusChoice.pending + ).order_by('-date_created').first() + @staticmethod def filter_spm_queryset(resource_ids, queryset): return queryset.filter(history_id__in=resource_ids) def get_queryset(self): - account = self.get_object() + account = self.account histories = account.history.all() - latest_history = account.history.first() + latest_history = self.latest_history if not latest_history: return histories if account.secret != latest_history.secret: @@ -144,3 +207,25 @@ class AccountHistoriesSecretAPI(ExtraFilterFieldsMixin, AccountRecordViewLogMixi return histories histories = histories.exclude(history_id=latest_history.history_id) return histories + + def filter_queryset(self, queryset): + queryset = super().filter_queryset(queryset) + queryset = list(queryset) + latest_history = self.latest_history + if not latest_history: + return queryset + + latest_change_secret_record = self.latest_change_secret_record + if not latest_change_secret_record: + return queryset + + if latest_change_secret_record.date_created > latest_history.history_date: + temp_history = self.model( + secret=latest_change_secret_record.new_secret, + secret_type=self.account.secret_type, + version=latest_history.version, + history_date=latest_change_secret_record.date_created, + ) + queryset = [temp_history] + queryset + + return queryset diff --git a/apps/accounts/api/account/application.py b/apps/accounts/api/account/application.py new file mode 100644 index 000000000..54a18572d --- /dev/null +++ b/apps/accounts/api/account/application.py @@ -0,0 +1,84 @@ +import os +from django.utils.translation import gettext_lazy as _, get_language +from django.conf import settings +from rest_framework.decorators import action +from rest_framework.response import Response + +from accounts import serializers +from accounts.models import IntegrationApplication +from audits.models import IntegrationApplicationLog +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 IntegrationApplicationViewSet(OrgBulkModelViewSet): + model = IntegrationApplication + search_fields = ('name', 'comment') + serializer_classes = { + 'default': serializers.IntegrationApplicationSerializer, + 'get_account_secret': serializers.IntegrationAccountSecretSerializer + } + rbac_perms = { + 'get_once_secret': 'accounts.change_integrationapplication', + 'get_account_secret': 'accounts.view_integrationapplication' + } + + def read_file(self, path): + if os.path.exists(path): + with open(path, 'r', encoding='utf-8') as file: + return file.read() + return '' + + @action( + ['GET'], detail=False, url_path='sdks', + permission_classes=[IsValidUser] + ) + def get_sdks_info(self, request, *args, **kwargs): + code_suffix_mapper = { + 'python': 'py', + 'java': 'java', + 'go': 'go', + 'node': 'js', + 'curl': 'sh', + } + sdk_language = request.query_params.get('language','python') + sdk_path = os.path.join(settings.APPS_DIR, 'accounts', 'demos', sdk_language) + readme_path = os.path.join(sdk_path, f'readme.{get_language()}.md') + demo_path = os.path.join(sdk_path, f'demo.{code_suffix_mapper[sdk_language]}') + + readme_content = self.read_file(readme_path) + demo_content = self.read_file(demo_path) + + return Response(data={'readme': readme_content, 'code': demo_content}) + + @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', + permission_classes=[RBACPermission]) + 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 + IntegrationApplicationLog.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/api/account/pam_dashboard.py b/apps/accounts/api/account/pam_dashboard.py new file mode 100644 index 000000000..35378de37 --- /dev/null +++ b/apps/accounts/api/account/pam_dashboard.py @@ -0,0 +1,132 @@ +# -*- coding: utf-8 -*- +# +from collections import defaultdict + +from django.db.models import Count, F, Q +from django.http.response import JsonResponse +from rest_framework.views import APIView + +from accounts.models import ( + Account, GatherAccountsAutomation, + PushAccountAutomation, BackupAccountAutomation, + AccountRisk, IntegrationApplication, ChangeSecretAutomation +) +from assets.const import AllTypes +from common.utils.timezone import local_monday + +__all__ = ['PamDashboardApi'] + + +class PamDashboardApi(APIView): + http_method_names = ['get'] + rbac_perms = { + 'GET': 'accounts.view_account', + } + + @staticmethod + def get_type_to_accounts(): + result = Account.objects.annotate(type=F('asset__platform__type')) \ + .values('type').order_by('type').annotate(total=Count(1)) + all_types_dict = dict(AllTypes.choices()) + + return [ + {**i, 'label': all_types_dict.get(i['type'], i['type'])} + for i in result + ] + + @staticmethod + def get_account_risk_data(_all, query_params): + agg_map = { + 'total_long_time_no_login_accounts': ('long_time_no_login_count', Q(risk='long_time_no_login')), + 'total_new_found_accounts': ('new_found_count', Q(risk='new_found')), + 'total_group_changed_accounts': ('group_changed_count', Q(risk='group_changed')), + 'total_sudo_changed_accounts': ('sudo_changed_count', Q(risk='sudo_changed')), + 'total_authorized_keys_changed_accounts': ( + 'authorized_keys_changed_count', Q(risk='authorized_keys_changed')), + 'total_account_deleted_accounts': ('account_deleted_count', Q(risk='account_deleted')), + 'total_password_expired_accounts': ('password_expired_count', Q(risk='password_expired')), + 'total_long_time_password_accounts': ('long_time_password_count', Q(risk='long_time_password')), + 'total_weak_password_accounts': ('weak_password_count', Q(risk='weak_password')), + 'total_leaked_password_accounts': ('leaked_password_count', Q(risk='leaked_password')), + 'total_repeated_password_accounts': ('repeated_password_count', Q(risk='repeated_password')), + 'total_password_error_accounts': ('password_error_count', Q(risk='password_error')), + 'total_no_admin_account_accounts': ('no_admin_account_count', Q(risk='no_admin_account')), + } + + aggregations = { + agg_key: Count('account_id', distinct=True, filter=agg_filter) + for param_key, (agg_key, agg_filter) in agg_map.items() + if _all or query_params.get(param_key) + } + + data = {} + if aggregations: + account_stats = AccountRisk.objects.filter(account__isnull=False).aggregate(**aggregations) + data = {param_key: account_stats.get(agg_key) for param_key, (agg_key, _) in agg_map.items() if + agg_key in account_stats} + + return data + + @staticmethod + def get_account_data(_all, query_params): + agg_map = { + 'total_accounts': ('total_count', Count('id')), + 'total_privileged_accounts': ('privileged_count', Count('id', filter=Q(privileged=True))), + 'total_connectivity_ok_accounts': ('connectivity_ok_count', Count('id', filter=Q(connectivity='ok'))), + 'total_secret_reset_accounts': ('secret_reset_count', Count('id', filter=Q(secret_reset=True))), + 'total_valid_accounts': ('valid_count', Count('id', filter=Q(is_active=True))), + 'total_week_add_accounts': ('week_add_count', Count('id', filter=Q(date_created__gte=local_monday()))), + } + + aggregations = { + agg_key: agg_expr + for param_key, (agg_key, agg_expr) in agg_map.items() + if _all or query_params.get(param_key) + } + + data = {} + account_stats = Account.objects.aggregate(**aggregations) + for param_key, (agg_key, __) in agg_map.items(): + if agg_key in account_stats: + data[param_key] = account_stats[agg_key] + + if _all or query_params.get('total_ordinary_accounts'): + if 'total_count' in account_stats and 'privileged_count' in account_stats: + data['total_ordinary_accounts'] = \ + account_stats['total_count'] - account_stats['privileged_count'] + + return data + + @staticmethod + def get_automation_counts(_all, query_params): + automation_counts = defaultdict(int) + automation_models = { + 'total_count_change_secret_automation': ChangeSecretAutomation, + 'total_count_gathered_account_automation': GatherAccountsAutomation, + 'total_count_push_account_automation': PushAccountAutomation, + 'total_count_backup_account_automation': BackupAccountAutomation, + 'total_count_integration_application': IntegrationApplication, + } + + for param_key, model in automation_models.items(): + if _all or query_params.get(param_key): + automation_counts[param_key] = model.objects.count() + + return automation_counts + + def get(self, request, *args, **kwargs): + query_params = self.request.query_params + + _all = query_params.get('all') + + data = {} + data.update(self.get_account_data(_all, query_params)) + data.update(self.get_account_risk_data(_all, query_params)) + data.update(self.get_automation_counts(_all, query_params)) + + if _all or query_params.get('total_count_type_to_accounts'): + data.update({ + 'total_count_type_to_accounts': self.get_type_to_accounts(), + }) + + return JsonResponse(data, status=200) diff --git a/apps/accounts/api/automations/__init__.py b/apps/accounts/api/automations/__init__.py index a03da88b0..a81361e35 100644 --- a/apps/accounts/api/automations/__init__.py +++ b/apps/accounts/api/automations/__init__.py @@ -1,5 +1,7 @@ from .backup import * from .base import * from .change_secret import * -from .gather_accounts 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/backup.py b/apps/accounts/api/automations/backup.py index 6810087f7..cf3c0c184 100644 --- a/apps/accounts/api/automations/backup.py +++ b/apps/accounts/api/automations/backup.py @@ -1,41 +1,36 @@ # -*- coding: utf-8 -*- # -from rest_framework import status, viewsets -from rest_framework.response import Response - from accounts import serializers +from accounts.const import AutomationTypes from accounts.models import ( - AccountBackupAutomation, AccountBackupExecution + BackupAccountAutomation ) -from accounts.tasks import execute_account_backup_task -from common.const.choices import Trigger from orgs.mixins.api import OrgBulkModelViewSet +from .base import AutomationExecutionViewSet __all__ = [ - 'AccountBackupPlanViewSet', 'AccountBackupPlanExecutionViewSet' + 'BackupAccountViewSet', 'BackupAccountExecutionViewSet' ] -class AccountBackupPlanViewSet(OrgBulkModelViewSet): - model = AccountBackupAutomation +class BackupAccountViewSet(OrgBulkModelViewSet): + model = BackupAccountAutomation filterset_fields = ('name',) search_fields = filterset_fields - serializer_class = serializers.AccountBackupSerializer + serializer_class = serializers.BackupAccountSerializer -class AccountBackupPlanExecutionViewSet(viewsets.ModelViewSet): - serializer_class = serializers.AccountBackupPlanExecutionSerializer - search_fields = ('trigger', 'plan__name') - filterset_fields = ('trigger', 'plan_id', 'plan__name') - http_method_names = ['get', 'post', 'options'] +class BackupAccountExecutionViewSet(AutomationExecutionViewSet): + rbac_perms = ( + ("list", "accounts.view_backupaccountexecution"), + ("retrieve", "accounts.view_backupaccountexecution"), + ("create", "accounts.add_backupaccountexecution"), + ("report", "accounts.view_backupaccountexecution"), + ) + + tp = AutomationTypes.backup_account def get_queryset(self): - queryset = AccountBackupExecution.objects.all() + queryset = super().get_queryset() + queryset = queryset.filter(automation__type=self.tp) return queryset - - def create(self, request, *args, **kwargs): - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) - pid = serializer.data.get('plan') - task = execute_account_backup_task.delay(pid=str(pid), trigger=Trigger.manual) - return Response({'task': task.id}, status=status.HTTP_201_CREATED) diff --git a/apps/accounts/api/automations/base.py b/apps/accounts/api/automations/base.py index a9692eae6..bc897f9c1 100644 --- a/apps/accounts/api/automations/base.py +++ b/apps/accounts/api/automations/base.py @@ -1,6 +1,8 @@ +from django.http import HttpResponse from django.shortcuts import get_object_or_404 from django.utils.translation import gettext_lazy as _ from rest_framework import status, mixins, viewsets +from rest_framework.decorators import action from rest_framework.response import Response from accounts.models import AutomationExecution @@ -98,7 +100,6 @@ class AutomationExecutionViewSet( search_fields = ('trigger', 'automation__name') filterset_fields = ('trigger', 'automation_id', 'automation__name') serializer_class = serializers.AutomationExecutionSerializer - tp: str def get_queryset(self): @@ -113,3 +114,10 @@ class AutomationExecutionViewSet( pid=str(automation.pk), trigger=Trigger.manual, tp=self.tp ) return Response({'task': task.id}, status=status.HTTP_201_CREATED) + + @action(methods=['get'], detail=True, url_path='report') + def report(self, request, *args, **kwargs): + execution = self.get_object() + report = execution.manager.gen_report() + return HttpResponse(report) + diff --git a/apps/accounts/api/automations/change_secret.py b/apps/accounts/api/automations/change_secret.py index 05ee515dc..8aa2d3f94 100644 --- a/apps/accounts/api/automations/change_secret.py +++ b/apps/accounts/api/automations/change_secret.py @@ -1,11 +1,12 @@ # -*- coding: utf-8 -*- # +from django.db.models import Max, Q, Subquery, OuterRef from rest_framework import status, mixins from rest_framework.decorators import action from rest_framework.response import Response from accounts import serializers -from accounts.const import AutomationTypes +from accounts.const import AutomationTypes, ChangeSecretRecordStatusChoice from accounts.filters import ChangeSecretRecordFilterSet from accounts.models import ChangeSecretAutomation, ChangeSecretRecord from accounts.tasks import execute_automation_record_task @@ -34,7 +35,8 @@ class ChangeSecretAutomationViewSet(OrgBulkModelViewSet): class ChangeSecretRecordViewSet(mixins.ListModelMixin, OrgGenericViewSet): filterset_class = ChangeSecretRecordFilterSet - search_fields = ('asset__address',) + search_fields = ('asset__address', 'account_username') + ordering_fields = ('date_finished',) tp = AutomationTypes.change_secret serializer_classes = { 'default': serializers.ChangeSecretRecordSerializer, @@ -43,6 +45,8 @@ class ChangeSecretRecordViewSet(mixins.ListModelMixin, OrgGenericViewSet): rbac_perms = { 'execute': 'accounts.add_changesecretexecution', 'secret': 'accounts.view_changesecretrecord', + 'dashboard': 'accounts.view_changesecretrecord', + 'ignore_fail': 'accounts.view_changesecretrecord', } def get_permissions(self): @@ -53,8 +57,37 @@ class ChangeSecretRecordViewSet(mixins.ListModelMixin, OrgGenericViewSet): ] return super().get_permissions() + def filter_queryset(self, queryset): + queryset = super().filter_queryset(queryset) + + if self.action == 'dashboard': + return self.get_dashboard_queryset(queryset) + return queryset + + @staticmethod + def get_dashboard_queryset(queryset): + recent_dates = queryset.values('account').annotate( + max_date_finished=Max('date_finished') + ) + + recent_success_accounts = queryset.filter( + account=OuterRef('account'), + date_finished=Subquery( + recent_dates.filter(account=OuterRef('account')).values('max_date_finished')[:1] + ) + ).filter(Q(status=ChangeSecretRecordStatusChoice.success) | Q(ignore_fail=True)) + + failed_records = queryset.filter( + ~Q(account__in=Subquery(recent_success_accounts.values('account'))), + status=ChangeSecretRecordStatusChoice.failed + ) + return failed_records + def get_queryset(self): - return ChangeSecretRecord.objects.all() + qs = ChangeSecretRecord.get_valid_records() + return qs.filter( + execution__automation__type=self.tp + ) @action(methods=['post'], detail=False, url_path='execute') def execute(self, request, *args, **kwargs): @@ -75,12 +108,24 @@ class ChangeSecretRecordViewSet(mixins.ListModelMixin, OrgGenericViewSet): serializer = self.get_serializer(instance) return Response(serializer.data) + @action(methods=['get'], detail=False, url_path='dashboard') + def dashboard(self, request, *args, **kwargs): + return super().list(request, *args, **kwargs) + + @action(methods=['patch'], detail=True, url_path='ignore-fail') + def ignore_fail(self, request, *args, **kwargs): + instance = self.get_object() + instance.ignore_fail = True + instance.save(update_fields=['ignore_fail']) + return Response(status=status.HTTP_200_OK) + class ChangSecretExecutionViewSet(AutomationExecutionViewSet): rbac_perms = ( ("list", "accounts.view_changesecretexecution"), ("retrieve", "accounts.view_changesecretexecution"), ("create", "accounts.add_changesecretexecution"), + ("report", "accounts.view_changesecretexecution"), ) tp = AutomationTypes.change_secret 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..2744cc757 --- /dev/null +++ b/apps/accounts/api/automations/change_secret_dashboard.py @@ -0,0 +1,169 @@ +# -*- 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 = {} + + _all = query_params.get('all') + + if _all or query_params.get('total_count_change_secrets'): + data['total_count_change_secrets'] = self.get_filtered_counts( + self.change_secrets_queryset, 'date_updated' + ) + + if _all or 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 _all or query_params.get('total_count_change_secret_assets'): + data['total_count_change_secret_assets'] = self.get_change_secret_asset_queryset().count() + + if _all or 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 _all or 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 _all or query_params.get('total_count_ongoing_change_secret'): + execution_ids = [] + inspect = app.control.inspect() + active_tasks = inspect.active() + if active_tasks: + 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/check_account.py b/apps/accounts/api/automations/check_account.py new file mode 100644 index 000000000..4eb360e97 --- /dev/null +++ b/apps/accounts/api/automations/check_account.py @@ -0,0 +1,153 @@ +# -*- coding: utf-8 -*- +# +from django.db.models import Q, Count +from django.http import HttpResponse +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from rest_framework.decorators import action +from rest_framework.exceptions import MethodNotAllowed +from rest_framework.response import Response + +from accounts import serializers +from accounts.const import AutomationTypes +from accounts.models import ( + CheckAccountAutomation, + AccountRisk, + RiskChoice, + CheckAccountEngine, + AutomationExecution, +) +from assets.models import Asset +from common.api import JMSModelViewSet +from common.utils import many_get +from orgs.mixins.api import OrgBulkModelViewSet +from .base import AutomationExecutionViewSet + +__all__ = [ + "CheckAccountAutomationViewSet", + "CheckAccountExecutionViewSet", + "AccountRiskViewSet", + "CheckAccountEngineViewSet", +] + +from ...risk_handlers import RiskHandler + + +class CheckAccountAutomationViewSet(OrgBulkModelViewSet): + model = CheckAccountAutomation + filterset_fields = ("name",) + search_fields = filterset_fields + serializer_class = serializers.CheckAccountAutomationSerializer + + +class CheckAccountExecutionViewSet(AutomationExecutionViewSet): + rbac_perms = ( + ("list", "accounts.view_checkaccountexecution"), + ("retrieve", "accounts.view_checkaccountsexecution"), + ("create", "accounts.add_checkaccountexecution"), + ("adhoc", "accounts.add_checkaccountexecution"), + ("report", "accounts.view_checkaccountsexecution"), + ) + ordering = ("-date_created",) + tp = AutomationTypes.check_account + + def get_queryset(self): + queryset = super().get_queryset() + queryset = queryset.filter(automation__type=self.tp) + return queryset + + @action(methods=["get"], detail=False, url_path="adhoc") + def adhoc(self, request, *args, **kwargs): + asset_id = request.query_params.get("asset_id") + if not asset_id: + return Response(status=400, data={"asset_id": "This field is required."}) + + get_object_or_404(Asset, pk=asset_id) + execution = AutomationExecution() + execution.snapshot = { + "assets": [asset_id], + "nodes": [], + "type": AutomationTypes.check_account, + "engines": ["check_account_secret"], + "name": "Check asset risk: {} {}".format(asset_id, timezone.now()), + } + execution.save() + execution.start() + report = execution.manager.gen_report() + return HttpResponse(report) + + +class AccountRiskViewSet(OrgBulkModelViewSet): + model = AccountRisk + search_fields = ("username", "asset") + filterset_fields = ("risk", "status", "asset") + serializer_classes = { + "default": serializers.AccountRiskSerializer, + "assets": serializers.AssetRiskSerializer, + "handle": serializers.HandleRiskSerializer, + } + ordering_fields = ("asset", "risk", "status", "username", "date_created") + ordering = ("status", "asset", "date_created") + rbac_perms = { + "sync_accounts": "assets.add_accountrisk", + "assets": "accounts.view_accountrisk", + "handle": "accounts.change_accountrisk", + } + + def update(self, request, *args, **kwargs): + raise MethodNotAllowed("PUT") + + def create(self, request, *args, **kwargs): + raise MethodNotAllowed("POST") + + @action(methods=["get"], detail=False, url_path="assets") + def assets(self, request, *args, **kwargs): + annotations = { + f"{risk[0]}_count": Count("id", filter=Q(risk=risk[0])) + for risk in RiskChoice.choices + } + queryset = ( + AccountRisk.objects.select_related( + "asset", "asset__platform" + ) # 使用 select_related 来优化 asset 和 asset__platform 的查询 + .values( + "asset__id", "asset__name", "asset__address", "asset__platform__name" + ) # 添加需要的字段 + .annotate(risk_total=Count("id")) # 计算风险总数 + .annotate(**annotations) # 使用上面定义的 annotations 进行计数 + ) + return self.get_paginated_response_from_queryset(queryset) + + @action(methods=["post"], detail=False, url_path="handle") + def handle(self, request, *args, **kwargs): + s = self.get_serializer(data=request.data) + s.is_valid(raise_exception=True) + + asset, username, act, risk = many_get( + s.validated_data, ("asset", "username", "action", "risk") + ) + handler = RiskHandler(asset=asset, username=username, request=self.request) + data = handler.handle(act, risk) + if not data: + data = {"message": "Success"} + s = serializers.AccountRiskSerializer(instance=data) + return Response(data=s.data) + + +class CheckAccountEngineViewSet(JMSModelViewSet): + search_fields = ("name",) + serializer_class = serializers.CheckAccountEngineSerializer + + perm_model = CheckAccountEngine + + def get_queryset(self): + return CheckAccountEngine.get_default_engines() + + def filter_queryset(self, queryset: list): + search = self.request.GET.get('search') + if search is not None: + queryset = [ + item for item in queryset + if search in item['name'] + ] + return queryset diff --git a/apps/accounts/api/automations/gather_account.py b/apps/accounts/api/automations/gather_account.py new file mode 100644 index 000000000..49f38d028 --- /dev/null +++ b/apps/accounts/api/automations/gather_account.py @@ -0,0 +1,123 @@ +# -*- coding: utf-8 -*- +# +from django.http import HttpResponse +from django.shortcuts import get_object_or_404 +from rest_framework import status +from rest_framework.decorators import action +from rest_framework.response import Response + +from accounts import serializers +from accounts.const import AutomationTypes +from accounts.filters import GatheredAccountFilterSet +from accounts.models import GatherAccountsAutomation, AutomationExecution, Account +from accounts.models import GatheredAccount +from assets.models import Asset +from common.utils.http import is_true +from orgs.mixins.api import OrgBulkModelViewSet +from .base import AutomationExecutionViewSet + +__all__ = [ + "DiscoverAccountsAutomationViewSet", + "DiscoverAccountsExecutionViewSet", + "GatheredAccountViewSet", +] + +from ...risk_handlers import RiskHandler + + +class DiscoverAccountsAutomationViewSet(OrgBulkModelViewSet): + model = GatherAccountsAutomation + filterset_fields = ("name",) + search_fields = filterset_fields + serializer_class = serializers.DiscoverAccountAutomationSerializer + + +class DiscoverAccountsExecutionViewSet(AutomationExecutionViewSet): + rbac_perms = ( + ("list", "accounts.view_gatheraccountsexecution"), + ("retrieve", "accounts.view_gatheraccountsexecution"), + ("create", "accounts.add_gatheraccountsexecution"), + ("adhoc", "accounts.add_gatheraccountsexecution"), + ("report", "accounts.view_gatheraccountsexecution"), + ) + + tp = AutomationTypes.gather_accounts + + def get_queryset(self): + queryset = super().get_queryset() + queryset = queryset.filter(automation__type=self.tp) + return queryset + + @action(methods=["get"], detail=False, url_path="adhoc") + def adhoc(self, request, *args, **kwargs): + asset_id = request.query_params.get("asset_id") + if not asset_id: + return Response(status=400, data={"asset_id": "This field is required."}) + + get_object_or_404(Asset, pk=asset_id) + execution = AutomationExecution() + execution.snapshot = { + "assets": [asset_id], + "nodes": [], + "type": "gather_accounts", + "is_sync_account": False, + "check_risk": True, + "name": "Adhoc gather accounts: {}".format(asset_id), + } + execution.save() + execution.start() + report = execution.manager.gen_report() + return HttpResponse(report) + + +class GatheredAccountViewSet(OrgBulkModelViewSet): + model = GatheredAccount + search_fields = ("username",) + filterset_class = GatheredAccountFilterSet + ordering = ("status",) + serializer_classes = { + "default": serializers.DiscoverAccountSerializer, + "status": serializers.DiscoverAccountActionSerializer, + "details": serializers.DiscoverAccountDetailsSerializer + } + rbac_perms = { + "status": "assets.change_gatheredaccount", + "details": "assets.view_gatheredaccount" + } + + @action(methods=["put"], detail=False, url_path="status") + def status(self, request, *args, **kwargs): + ids = request.data.get('ids', []) + new_status = request.data.get("status") + updated_instances = GatheredAccount.objects.filter(id__in=ids) + updated_instances.update(status=new_status) + if new_status == "confirmed": + GatheredAccount.sync_accounts(updated_instances) + + return Response(status=status.HTTP_200_OK) + + def perform_destroy(self, instance): + request = self.request + params = request.query_params + is_delete_remote = params.get("is_delete_remote") + is_delete_account = params.get("is_delete_account") + asset_id = params.get("asset") + username = params.get("username") + if is_true(is_delete_remote): + self._delete_remote(asset_id, username) + if is_true(is_delete_account): + account = get_object_or_404(Account, username=username, asset_id=asset_id) + account.delete() + super().perform_destroy(instance) + + def _delete_remote(self, asset_id, username): + asset = get_object_or_404(Asset, pk=asset_id) + handler = RiskHandler(asset, username, request=self.request) + handler.handle_delete_remote() + + @action(methods=["get"], detail=True, url_path="details") + def details(self, request, *args, **kwargs): + pk = kwargs.get('pk') + account = get_object_or_404(GatheredAccount, pk=pk) + serializer = self.get_serializer(account.detail) + return Response(data=serializer.data) diff --git a/apps/accounts/api/automations/gather_accounts.py b/apps/accounts/api/automations/gather_accounts.py deleted file mode 100644 index e125ed96b..000000000 --- a/apps/accounts/api/automations/gather_accounts.py +++ /dev/null @@ -1,59 +0,0 @@ -# -*- coding: utf-8 -*- -# -from rest_framework import status -from rest_framework.decorators import action -from rest_framework.response import Response - -from accounts import serializers -from accounts.const import AutomationTypes -from accounts.filters import GatheredAccountFilterSet -from accounts.models import GatherAccountsAutomation -from accounts.models import GatheredAccount -from orgs.mixins.api import OrgBulkModelViewSet -from .base import AutomationExecutionViewSet - -__all__ = [ - 'GatherAccountsAutomationViewSet', 'GatherAccountsExecutionViewSet', - 'GatheredAccountViewSet' -] - - -class GatherAccountsAutomationViewSet(OrgBulkModelViewSet): - model = GatherAccountsAutomation - filterset_fields = ('name',) - search_fields = filterset_fields - serializer_class = serializers.GatherAccountAutomationSerializer - - -class GatherAccountsExecutionViewSet(AutomationExecutionViewSet): - rbac_perms = ( - ("list", "accounts.view_gatheraccountsexecution"), - ("retrieve", "accounts.view_gatheraccountsexecution"), - ("create", "accounts.add_gatheraccountsexecution"), - ) - - tp = AutomationTypes.gather_accounts - - def get_queryset(self): - queryset = super().get_queryset() - queryset = queryset.filter(automation__type=self.tp) - return queryset - - -class GatheredAccountViewSet(OrgBulkModelViewSet): - model = GatheredAccount - search_fields = ('username',) - filterset_class = GatheredAccountFilterSet - serializer_classes = { - 'default': serializers.GatheredAccountSerializer, - } - rbac_perms = { - 'sync_accounts': 'assets.add_gatheredaccount', - } - - @action(methods=['post'], detail=False, url_path='sync-accounts') - def sync_accounts(self, request, *args, **kwargs): - gathered_account_ids = request.data.get('gathered_account_ids') - gathered_accounts = self.model.objects.filter(id__in=gathered_account_ids) - self.model.sync_accounts(gathered_accounts) - return Response(status=status.HTTP_201_CREATED) diff --git a/apps/accounts/api/automations/push_account.py b/apps/accounts/api/automations/push_account.py index 1fa5c1219..e09873af5 100644 --- a/apps/accounts/api/automations/push_account.py +++ b/apps/accounts/api/automations/push_account.py @@ -1,15 +1,16 @@ # -*- coding: utf-8 -*- # +from rest_framework import mixins + from accounts import serializers from accounts.const import AutomationTypes -from accounts.models import PushAccountAutomation, ChangeSecretRecord -from orgs.mixins.api import OrgBulkModelViewSet - +from accounts.filters import PushAccountRecordFilterSet +from accounts.models import PushAccountAutomation, PushSecretRecord +from orgs.mixins.api import OrgBulkModelViewSet, OrgGenericViewSet from .base import ( AutomationAssetsListApi, AutomationRemoveAssetApi, AutomationAddAssetApi, AutomationNodeAddRemoveApi, AutomationExecutionViewSet ) -from .change_secret import ChangeSecretRecordViewSet __all__ = [ 'PushAccountAutomationViewSet', 'PushAccountAssetsListApi', 'PushAccountRemoveAssetApi', @@ -30,6 +31,7 @@ class PushAccountExecutionViewSet(AutomationExecutionViewSet): ("list", "accounts.view_pushaccountexecution"), ("retrieve", "accounts.view_pushaccountexecution"), ("create", "accounts.add_pushaccountexecution"), + ("report", "accounts.view_pushaccountexecution"), ) tp = AutomationTypes.push_account @@ -40,13 +42,19 @@ class PushAccountExecutionViewSet(AutomationExecutionViewSet): return queryset -class PushAccountRecordViewSet(ChangeSecretRecordViewSet): - serializer_class = serializers.ChangeSecretRecordSerializer +class PushAccountRecordViewSet(mixins.ListModelMixin, OrgGenericViewSet): + filterset_class = PushAccountRecordFilterSet + search_fields = ('asset__address', 'account_username') + ordering_fields = ('date_finished',) tp = AutomationTypes.push_account + serializer_classes = { + 'default': serializers.PushSecretRecordSerializer, + } def get_queryset(self): - return ChangeSecretRecord.objects.filter( - execution__automation__type=AutomationTypes.push_account + qs = PushSecretRecord.get_valid_records() + return qs.filter( + execution__automation__type=self.tp ) diff --git a/apps/accounts/automations/backup_account/handlers.py b/apps/accounts/automations/backup_account/handlers.py index 5ca137cd3..b10cc0fc4 100644 --- a/apps/accounts/automations/backup_account/handlers.py +++ b/apps/accounts/automations/backup_account/handlers.py @@ -3,15 +3,17 @@ import time from collections import defaultdict, OrderedDict from django.conf import settings +from django.db.models import F from django.utils.translation import gettext_lazy as _ from rest_framework import serializers from xlsxwriter import Workbook from accounts.const import AccountBackupType -from accounts.models.automations.backup_account import AccountBackupAutomation +from accounts.models import BackupAccountAutomation, Account from accounts.notifications import AccountBackupExecutionTaskMsg, AccountBackupByObjStorageExecutionTaskMsg from accounts.serializers import AccountSecretSerializer from assets.const import AllTypes +from common.const import Status from common.utils.file import encrypt_and_compress_zip_file, zip_files from common.utils.timezone import local_now_filename, local_now_display from terminal.models.component.storage import ReplayStorage @@ -20,6 +22,7 @@ from users.models import User PATH = os.path.join(os.path.dirname(settings.BASE_DIR), 'tmp') split_help_text = _('The account key will be split into two parts and sent') + class RecipientsNotFound(Exception): pass @@ -73,9 +76,9 @@ class BaseAccountHandler: class AssetAccountHandler(BaseAccountHandler): @staticmethod - def get_filename(plan_name): + def get_filename(name): filename = os.path.join( - PATH, f'{plan_name}-{local_now_filename()}-{time.time()}.xlsx' + PATH, f'{name}-{local_now_filename()}-{time.time()}.xlsx' ) return filename @@ -117,32 +120,41 @@ class AssetAccountHandler(BaseAccountHandler): cls.handler_secret(data, section) data_map.update(cls.add_rows(data, header_fields, sheet_name)) number_of_backup_accounts = _('Number of backup accounts') - print('\n\033[33m- {}: {}\033[0m'.format(number_of_backup_accounts, accounts.count())) + print('\033[33m- {}: {}\033[0m'.format(number_of_backup_accounts, accounts.count())) return data_map class AccountBackupHandler: - def __init__(self, execution): + def __init__(self, manager, execution): + self.manager = manager self.execution = execution - self.plan_name = self.execution.plan.name - self.is_frozen = False # 任务状态冻结标志 + self.name = self.execution.snapshot.get('name', '-') + + def get_accounts(self): + # TODO 可以优化一下查询 在账号上做 category 的缓存 避免数据量大时连表操作 + types = self.execution.snapshot.get('types', []) + self.manager.summary['total_types'] = len(types) + qs = Account.objects.filter( + asset__platform__type__in=types + ).annotate(type=F('asset__platform__type')) + return qs def create_excel(self, section='complete'): - hint = _('Generating asset or application related backup information files') + hint = _('Generating asset related backup information files') print( - '\n' f'\033[32m>>> {hint}\033[0m' '' ) - # Print task start date + time_start = time.time() files = [] - accounts = self.execution.backup_accounts + accounts = self.get_accounts() + self.manager.summary['total_accounts'] = accounts.count() data_map = AssetAccountHandler.create_data_map(accounts, section) if not data_map: return files - filename = AssetAccountHandler.get_filename(self.plan_name) + filename = AssetAccountHandler.get_filename(self.name) wb = Workbook(filename) for sheet, data in data_map.items(): @@ -153,7 +165,7 @@ class AccountBackupHandler: wb.close() files.append(filename) timedelta = round((time.time() - time_start), 2) - time_cost = _('Time cost') + time_cost = _('Duration') file_created = _('Backup file creation completed') print('{}: {} {}s'.format(file_created, time_cost, timedelta)) return files @@ -163,21 +175,19 @@ class AccountBackupHandler: return recipients = User.objects.filter(id__in=list(recipients)) print( - '\n' f'\033[32m>>> {_("Start sending backup emails")}\033[0m' '' ) - plan_name = self.plan_name + name = self.name for user in recipients: if not user.secret_key: attachment_list = [] else: - attachment = os.path.join(PATH, f'{plan_name}-{local_now_filename()}-{time.time()}.zip') + attachment = os.path.join(PATH, f'{name}-{local_now_filename()}-{time.time()}.zip') encrypt_and_compress_zip_file(attachment, user.secret_key, files) - attachment_list = [attachment, ] - AccountBackupExecutionTaskMsg(plan_name, user).publish(attachment_list) - email_sent_to = _('Email sent to') - print('{} {}({})'.format(email_sent_to, user, user.email)) + attachment_list = [attachment] + AccountBackupExecutionTaskMsg(name, user).publish(attachment_list) + for file in files: os.remove(file) @@ -186,63 +196,41 @@ class AccountBackupHandler: return recipients = ReplayStorage.objects.filter(id__in=list(recipients)) print( - '\n' '\033[32m>>> 📃 ---> sftp \033[0m' '' ) - plan_name = self.plan_name + name = self.name encrypt_file = _('Encrypting files using encryption password') for rec in recipients: - attachment = os.path.join(PATH, f'{plan_name}-{local_now_filename()}-{time.time()}.zip') + attachment = os.path.join(PATH, f'{name}-{local_now_filename()}-{time.time()}.zip') if password: print(f'\033[32m>>> {encrypt_file}\033[0m') encrypt_and_compress_zip_file(attachment, password, files) else: zip_files(attachment, files) attachment_list = attachment - AccountBackupByObjStorageExecutionTaskMsg(plan_name, rec).publish(attachment_list) + AccountBackupByObjStorageExecutionTaskMsg(name, rec).publish(attachment_list) file_sent_to = _('The backup file will be sent to') print('{}: {}({})'.format(file_sent_to, rec.name, rec.id)) for file in files: os.remove(file) - def step_perform_task_update(self, is_success, reason): - self.execution.reason = reason[:1024] - self.execution.is_success = is_success - self.execution.save() - finish = _('Finish') - print(f'\n{finish}\n') - - @staticmethod - def step_finished(is_success): - if is_success: - print(_('Success')) - else: - print(_('Failed')) - def _run(self): - is_success = False - error = '-' try: - backup_type = self.execution.snapshot.get('backup_type', AccountBackupType.email.value) - if backup_type == AccountBackupType.email.value: + backup_type = self.execution.snapshot.get('backup_type', AccountBackupType.email) + if backup_type == AccountBackupType.email: self.backup_by_email() - elif backup_type == AccountBackupType.object_storage.value: + elif backup_type == AccountBackupType.object_storage: self.backup_by_obj_storage() except Exception as e: - self.is_frozen = True - print(e) error = str(e) - else: - is_success = True - finally: - reason = error - self.step_perform_task_update(is_success, reason) - self.step_finished(is_success) + print(f'\033[31m>>> {error}\033[0m') + self.execution.status = Status.error + self.execution.summary['error'] = error def backup_by_obj_storage(self): object_id = self.execution.snapshot.get('id') - zip_encrypt_password = AccountBackupAutomation.objects.get(id=object_id).zip_encrypt_password + zip_encrypt_password = BackupAccountAutomation.objects.get(id=object_id).zip_encrypt_password obj_recipients_part_one = self.execution.snapshot.get('obj_recipients_part_one', []) obj_recipients_part_two = self.execution.snapshot.get('obj_recipients_part_two', []) no_assigned_sftp_server = _('The backup task has no assigned sftp server') @@ -266,7 +254,6 @@ class AccountBackupHandler: self.send_backup_obj_storage(files, recipients, zip_encrypt_password) def backup_by_email(self): - warn_text = _('The backup task has no assigned recipient') recipients_part_one = self.execution.snapshot.get('recipients_part_one', []) recipients_part_two = self.execution.snapshot.get('recipients_part_two', []) @@ -276,7 +263,7 @@ class AccountBackupHandler: f'\033[31m>>> {warn_text}\033[0m' '' ) - raise RecipientsNotFound('Not Found Recipients') + return if recipients_part_one and recipients_part_two: print(f'\033[32m>>> {split_help_text}\033[0m') files = self.create_excel(section='front') @@ -290,18 +277,5 @@ class AccountBackupHandler: self.send_backup_mail(files, recipients) def run(self): - plan_start = _('Plan start') - plan_end = _('Plan end') - time_cost = _('Time cost') - error = _('An exception occurred during task execution') - print('{}: {}'.format(plan_start, local_now_display())) - time_start = time.time() - try: - self._run() - except Exception as e: - print(error) - print(e) - finally: - print('\n{}: {}'.format(plan_end, local_now_display())) - timedelta = round((time.time() - time_start), 2) - print('{}: {}s'.format(time_cost, timedelta)) + print('{}: {}'.format(_('Plan start'), local_now_display())) + self._run() diff --git a/apps/accounts/automations/backup_account/manager.py b/apps/accounts/automations/backup_account/manager.py index f98bd9c9e..70f1f591b 100644 --- a/apps/accounts/automations/backup_account/manager.py +++ b/apps/accounts/automations/backup_account/manager.py @@ -1,48 +1,30 @@ # -*- coding: utf-8 -*- # -import time -from django.utils import timezone from django.utils.translation import gettext_lazy as _ +from assets.automations.base.manager import BaseManager from common.utils.timezone import local_now_display from .handlers import AccountBackupHandler -class AccountBackupManager: - def __init__(self, execution): - self.execution = execution - self.date_start = timezone.now() - self.time_start = time.time() - self.date_end = None - self.time_end = None - self.timedelta = 0 - +class AccountBackupManager(BaseManager): def do_run(self): execution = self.execution account_backup_execution_being_executed = _('The account backup plan is being executed') - print(f'\n\033[33m# {account_backup_execution_being_executed}\033[0m') - handler = AccountBackupHandler(execution) + print(f'\033[33m# {account_backup_execution_being_executed}\033[0m') + handler = AccountBackupHandler(self, execution) handler.run() - def pre_run(self): - self.execution.date_start = self.date_start - self.execution.save() - - def post_run(self): - self.time_end = time.time() - self.date_end = timezone.now() + def send_report_if_need(self): + pass + def print_summary(self): print('\n\n' + '-' * 80) plan_execution_end = _('Plan execution end') print('{} {}\n'.format(plan_execution_end, local_now_display())) - self.timedelta = self.time_end - self.time_start - time_cost = _('Time cost') - print('{}: {}s'.format(time_cost, self.timedelta)) - self.execution.timedelta = self.timedelta - self.execution.save() + time_cost = _('Duration') + print('{}: {}s'.format(time_cost, self.duration)) - def run(self): - self.pre_run() - self.do_run() - self.post_run() + def get_report_template(self): + return "accounts/backup_account_report.html" diff --git a/apps/accounts/automations/base/manager.py b/apps/accounts/automations/base/manager.py index 401304597..b52dd4c2a 100644 --- a/apps/accounts/automations/base/manager.py +++ b/apps/accounts/automations/base/manager.py @@ -1,12 +1,173 @@ +from copy import deepcopy + +from django.conf import settings +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + from accounts.automations.methods import platform_automation_methods +from accounts.const import SSHKeyStrategy, SecretStrategy, SecretType, ChangeSecretRecordStatusChoice +from accounts.models import BaseAccountQuerySet from assets.automations.base.manager import BasePlaybookManager +from assets.const import HostTypes +from common.db.utils import safe_db_connection from common.utils import get_logger logger = get_logger(__name__) class AccountBasePlaybookManager(BasePlaybookManager): + template_path = '' @property def platform_automation_methods(self): return platform_automation_methods + + +class BaseChangeSecretPushManager(AccountBasePlaybookManager): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.secret_type = self.execution.snapshot.get('secret_type') + self.secret_strategy = self.execution.snapshot.get( + 'secret_strategy', SecretStrategy.custom + ) + self.ssh_key_change_strategy = self.execution.snapshot.get( + 'ssh_key_change_strategy', SSHKeyStrategy.set_jms + ) + self.account_ids = self.execution.snapshot['accounts'] + self.record_map = self.execution.snapshot.get('record_map', {}) # 这个是某个失败的记录重试 + self.name_recorder_mapper = {} # 做个映射,方便后面处理 + + def gen_account_inventory(self, account, asset, h, path_dir): + raise NotImplementedError + + def get_ssh_params(self, secret, secret_type): + kwargs = {} + if secret_type != SecretType.SSH_KEY: + return kwargs + kwargs['strategy'] = self.ssh_key_change_strategy + kwargs['exclusive'] = 'yes' if kwargs['strategy'] == SSHKeyStrategy.set else 'no' + + if kwargs['strategy'] == SSHKeyStrategy.set_jms: + kwargs['regexp'] = '.*{}$'.format(secret.split()[2].strip()) + return kwargs + + def get_accounts(self, privilege_account) -> BaseAccountQuerySet | None: + if not privilege_account: + print('Not privilege account') + return + + asset = privilege_account.asset + accounts = asset.accounts.all() + accounts = accounts.filter(id__in=self.account_ids, secret_reset=True) + + if self.secret_type: + accounts = accounts.filter(secret_type=self.secret_type) + + if settings.CHANGE_AUTH_PLAN_SECURE_MODE_ENABLED: + accounts = accounts.filter(privileged=False).exclude( + username__in=['root', 'administrator', privilege_account.username] + ) + return accounts + + def handle_ssh_secret(self, secret_type, new_secret, path_dir): + private_key_path = None + if secret_type == SecretType.SSH_KEY: + private_key_path = self.generate_private_key_path(new_secret, path_dir) + new_secret = self.generate_public_key(new_secret) + return new_secret, private_key_path + + def gen_inventory(self, h, account, new_secret, private_key_path, asset): + secret_type = account.secret_type + h['ssh_params'].update(self.get_ssh_params(new_secret, secret_type)) + h['account'] = { + 'name': account.name, + 'username': account.username, + 'secret_type': secret_type, + 'secret': account.escape_jinja2_syntax(new_secret), + 'private_key_path': private_key_path, + 'become': account.get_ansible_become_auth(), + } + if asset.platform.type == 'oracle': + h['account']['mode'] = 'sysdba' if account.privileged else None + return h + + def host_callback(self, host, asset=None, account=None, automation=None, path_dir=None, **kwargs): + host = super().host_callback( + host, asset=asset, account=account, automation=automation, + path_dir=path_dir, **kwargs + ) + if host.get('error'): + return host + + host['check_conn_after_change'] = self.execution.snapshot.get('check_conn_after_change', True) + host['ssh_params'] = {} + + accounts = self.get_accounts(account) + error_msg = _("No pending accounts found") + if not accounts: + print(f'{asset}: {error_msg}') + return [] + + if asset.type == HostTypes.WINDOWS: + accounts = accounts.filter(secret_type=SecretType.PASSWORD) + + inventory_hosts = [] + if asset.type == HostTypes.WINDOWS and self.secret_type == SecretType.SSH_KEY: + print(f'Windows {asset} does not support ssh key push') + return inventory_hosts + + for account in accounts: + h = deepcopy(host) + h['name'] += '(' + account.username + ')' # To distinguish different accounts + h = self.gen_account_inventory(account, asset, h, path_dir) + inventory_hosts.append(h) + + return inventory_hosts + + def on_host_success(self, host, result): + recorder = self.name_recorder_mapper.get(host) + if not recorder: + return + recorder.status = ChangeSecretRecordStatusChoice.success.value + recorder.date_finished = timezone.now() + + account = recorder.account + if not account: + print("Account not found, deleted ?") + return + + account.secret = getattr(recorder, 'new_secret', account.secret) + account.date_updated = timezone.now() + + with safe_db_connection(): + recorder.save(update_fields=['status', 'date_finished']) + account.save(update_fields=['secret', 'date_updated']) + + self.summary['ok_accounts'] += 1 + self.result['ok_accounts'].append( + { + "asset": str(account.asset), + "username": account.username, + } + ) + super().on_host_success(host, result) + + def on_host_error(self, host, error, result): + recorder = self.name_recorder_mapper.get(host) + if not recorder: + return + recorder.status = ChangeSecretRecordStatusChoice.failed.value + recorder.date_finished = timezone.now() + recorder.error = error + try: + recorder.save() + except Exception as e: + print(f"\033[31m Save {host} recorder error: {e} \033[0m\n") + self.summary['fail_accounts'] += 1 + self.result['fail_accounts'].append( + { + "asset": str(recorder.asset), + "username": recorder.account.username, + } + ) + super().on_host_error(host, error, result) diff --git a/apps/accounts/automations/change_secret/custom/ssh/main.yml b/apps/accounts/automations/change_secret/custom/ssh/main.yml index 54707a7d5..9d8d03e99 100644 --- a/apps/accounts/automations/change_secret/custom/ssh/main.yml +++ b/apps/accounts/automations/change_secret/custom/ssh/main.yml @@ -20,6 +20,7 @@ become_private_key_path: "{{ jms_custom_become_private_key_path | default(None) }}" old_ssh_version: "{{ jms_asset.old_ssh_version | default(False) }}" gateway_args: "{{ jms_asset.ansible_ssh_common_args | default(None) }}" + recv_timeout: "{{ params.recv_timeout | default(30) }}" register: ping_info delegate_to: localhost @@ -39,9 +40,12 @@ name: "{{ account.username }}" password: "{{ account.secret }}" commands: "{{ params.commands }}" - first_conn_delay_time: "{{ first_conn_delay_time | default(0.5) }}" + answers: "{{ params.answers }}" + recv_timeout: "{{ params.recv_timeout | default(30) }}" + delay_time: "{{ params.delay_time | default(2) }}" + prompt: "{{ params.prompt | default('.*') }}" ignore_errors: true - when: ping_info is succeeded + when: ping_info is succeeded and check_conn_after_change register: change_info delegate_to: localhost @@ -58,4 +62,6 @@ become_private_key_path: "{{ account.become.ansible_ssh_private_key_file | default(None) }}" old_ssh_version: "{{ jms_asset.old_ssh_version | default(False) }}" gateway_args: "{{ jms_asset.ansible_ssh_common_args | default(None) }}" + recv_timeout: "{{ params.recv_timeout | default(30) }}" delegate_to: localhost + when: check_conn_after_change \ No newline at end of file diff --git a/apps/accounts/automations/change_secret/custom/ssh/manifest.yml b/apps/accounts/automations/change_secret/custom/ssh/manifest.yml index 1b0a38a00..22fa3759c 100644 --- a/apps/accounts/automations/change_secret/custom/ssh/manifest.yml +++ b/apps/accounts/automations/change_secret/custom/ssh/manifest.yml @@ -10,10 +10,30 @@ protocol: ssh priority: 50 params: - name: commands - type: list + type: text label: "{{ 'Params commands label' | trans }}" - default: [ '' ] + default: '' help_text: "{{ 'Params commands help text' | trans }}" + - name: recv_timeout + type: int + label: "{{ 'Params recv_timeout label' | trans }}" + default: 30 + help_text: "{{ 'Params recv_timeout help text' | trans }}" + - name: delay_time + type: int + label: "{{ 'Params delay_time label' | trans }}" + default: 2 + help_text: "{{ 'Params delay_time help text' | trans }}" + - name: prompt + type: str + label: "{{ 'Params prompt label' | trans }}" + default: '.*' + help_text: "{{ 'Params prompt help text' | trans }}" + - name: answers + type: text + label: "{{ 'Params answer label' | trans }}" + default: '.*' + help_text: "{{ 'Params answer help text' | trans }}" i18n: SSH account change secret: @@ -22,11 +42,91 @@ i18n: en: 'Custom password change by SSH command line' Params commands help text: - zh: '自定义命令中如需包含账号的 账号、密码、SSH 连接的用户密码 字段,
请使用 {username}、{password}、{login_password}格式,执行任务时会进行替换 。
比如针对 Cisco 主机进行改密,一般需要配置五条命令:
1. enable
2. {login_password}
3. configure terminal
4. username {username} privilege 0 password {password}
5. end' - ja: 'カスタム コマンドに SSH 接続用のアカウント番号、パスワード、ユーザー パスワード フィールドを含める必要がある場合は、
{ユーザー名}、{パスワード}、{login_password& を使用してください。 # 125; 形式。タスクの実行時に置き換えられます。
たとえば、Cisco ホストのパスワードを変更するには、通常、次の 5 つのコマンドを設定する必要があります:
1.enable
2.{login_password}
3 .ターミナルの設定
4. ユーザー名 {ユーザー名} 権限 0 パスワード {パスワード}
5. 終了' - en: 'If the custom command needs to include the account number, password, and user password field for SSH connection,
Please use {username}, {password}, {login_password&# 125; format, which will be replaced when executing the task.
For example, to change the password of a Cisco host, you generally need to configure five commands:
1. enable
2. {login_password}
3. configure terminal
4. username {username} privilege 0 password {password}
5. end' + zh: | + 请将命令中的指定位置改成特殊符号
+ 1. 改密账号 -> {username}
+ 2. 改密密码 -> {password}
+ 3. 登录用户密码 -> {login_password}
+ 多条命令使用换行分割,执行任务时系统会根据特殊符号替换真实数据。
+ 比如针对 Cisco 主机进行改密,一般需要配置五条命令:
+ enable
+ {login_password}
+ configure terminal
+ username {username} privilege 0 password {password}
+ end
+ ja: | + コマンド内の指定された位置を特殊記号に変更してください。
+ 新しいパスワード(アカウント変更) -> {username}
+ 新しいパスワード(パスワード変更) -> {password}
+ ログインユーザーパスワード -> {login_password}
+ 複数のコマンドは改行で区切り、タスクを実行するときにシステムは特殊記号を使用して実際のデータを置き換えます。
+ 例えば、Cisco機器のパスワードを変更する場合、一般的には5つのコマンドを設定する必要があります:
+ enable
+ {login_password}
+ configure terminal
+ username {username} privilege 0 password {password}
+ end
+ en: | + Please change the specified positions in the command to special symbols.
+ Change password account -> {username}
+ Change password -> {password}
+ Login user password -> {login_password}
+ Multiple commands are separated by new lines, and when executing tasks,
+ the system will replace the special symbols with real data.
+ For example, to change the password for a Cisco device, you generally need to configure five commands:
+ enable
+ {login_password}
+ configure terminal
+ username {username} privilege 0 password {password}
+ end
Params commands label: zh: '自定义命令' ja: 'カスタムコマンド' en: 'Custom command' + + Params recv_timeout label: + zh: '超时时间' + ja: 'タイムアウト' + en: 'Timeout' + + Params recv_timeout help text: + zh: '等待命令结果返回的超时时间(秒)' + ja: 'コマンドの結果を待つタイムアウト時間(秒)' + en: 'The timeout for waiting for the command result to return (Seconds)' + + Params delay_time label: + zh: '延迟发送时间' + ja: '遅延送信時間' + en: 'Delayed send time' + + Params delay_time help text: + zh: '每条命令延迟发送的时间间隔(秒)' + ja: '各コマンド送信の遅延間隔(秒)' + en: 'Time interval for each command delay in sending (Seconds)' + + Params prompt label: + zh: '提示符' + ja: 'ヒント' + en: 'Prompt' + + Params prompt help text: + zh: '终端连接后显示的提示符信息(正则表达式)' + ja: 'ターミナル接続後に表示されるプロンプト情報(正規表現)' + en: 'Prompt information displayed after terminal connection (Regular expression)' + + Params answer label: + zh: '命令结果' + ja: 'コマンド結果' + en: 'Command result' + + Params answer help text: + zh: | + 根据结果匹配度决定是否执行下一条命令,输入框的内容和上方 “自定义命令” 内容按行一一对应(正则表达式) + ja: | + 結果の一致度に基づいて次のコマンドを実行するかどうかを決定します。 + 入力欄の内容は、上の「カスタムコマンド」の内容と行ごとに対応しています(せいきひょうげん) + en: | + Decide whether to execute the next command based on the result match. + The input content corresponds line by line with the content + of the `Custom command` above. (Regular expression) diff --git a/apps/accounts/automations/change_secret/database/mongodb/main.yml b/apps/accounts/automations/change_secret/database/mongodb/main.yml index 8ea631c18..36fb90579 100644 --- a/apps/accounts/automations/change_secret/database/mongodb/main.yml +++ b/apps/accounts/automations/change_secret/database/mongodb/main.yml @@ -1,7 +1,7 @@ - hosts: mongodb gather_facts: no vars: - ansible_python_interpreter: /opt/py3/bin/python + ansible_python_interpreter: "{{ local_python_interpreter }}" tasks: - name: Test MongoDB connection @@ -53,3 +53,4 @@ ssl_certfile: "{{ jms_asset.secret_info.client_key | default('') }}" connection_options: - tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert}}" + when: check_conn_after_change \ No newline at end of file diff --git a/apps/accounts/automations/change_secret/database/mysql/main.yml b/apps/accounts/automations/change_secret/database/mysql/main.yml index 0d8452a4a..4cfb09380 100644 --- a/apps/accounts/automations/change_secret/database/mysql/main.yml +++ b/apps/accounts/automations/change_secret/database/mysql/main.yml @@ -1,7 +1,7 @@ - hosts: mysql gather_facts: no vars: - ansible_python_interpreter: /opt/py3/bin/python + ansible_python_interpreter: "{{ local_python_interpreter }}" db_name: "{{ jms_asset.spec_info.db_name }}" check_ssl: "{{ jms_asset.spec_info.use_ssl and not jms_asset.spec_info.allow_invalid_cert }}" ca_cert: "{{ jms_asset.secret_info.ca_cert | default('') }}" @@ -54,3 +54,4 @@ client_cert: "{{ ssl_cert if check_ssl and ssl_cert | length > 0 else omit }}" client_key: "{{ ssl_key if check_ssl and ssl_key | length > 0 else omit }}" filter: version + when: check_conn_after_change \ No newline at end of file diff --git a/apps/accounts/automations/change_secret/database/oracle/main.yml b/apps/accounts/automations/change_secret/database/oracle/main.yml index 5a94f3184..6b91b7a2b 100644 --- a/apps/accounts/automations/change_secret/database/oracle/main.yml +++ b/apps/accounts/automations/change_secret/database/oracle/main.yml @@ -1,7 +1,7 @@ - hosts: oracle gather_facts: no vars: - ansible_python_interpreter: /opt/py3/bin/python + ansible_python_interpreter: "{{ local_python_interpreter }}" tasks: - name: Test Oracle connection @@ -40,3 +40,4 @@ login_port: "{{ jms_asset.port }}" login_database: "{{ jms_asset.spec_info.db_name }}" mode: "{{ account.mode }}" + when: check_conn_after_change diff --git a/apps/accounts/automations/change_secret/database/postgresql/main.yml b/apps/accounts/automations/change_secret/database/postgresql/main.yml index 34dea98b7..bfa09217c 100644 --- a/apps/accounts/automations/change_secret/database/postgresql/main.yml +++ b/apps/accounts/automations/change_secret/database/postgresql/main.yml @@ -1,7 +1,7 @@ - hosts: postgre gather_facts: no vars: - ansible_python_interpreter: /opt/py3/bin/python + ansible_python_interpreter: "{{ local_python_interpreter }}" check_ssl: "{{ jms_asset.spec_info.use_ssl }}" ca_cert: "{{ jms_asset.secret_info.ca_cert | default('') }}" ssl_cert: "{{ jms_asset.secret_info.client_cert | default('') }}" @@ -55,3 +55,4 @@ ssl_cert: "{{ ssl_cert if check_ssl and ssl_cert | length > 0 else omit }}" ssl_key: "{{ ssl_key if check_ssl and ssl_key | length > 0 else omit }}" ssl_mode: "{{ jms_asset.spec_info.pg_ssl_mode }}" + when: check_conn_after_change diff --git a/apps/accounts/automations/change_secret/database/sqlserver/main.yml b/apps/accounts/automations/change_secret/database/sqlserver/main.yml index eb1746c09..4e8cd5d52 100644 --- a/apps/accounts/automations/change_secret/database/sqlserver/main.yml +++ b/apps/accounts/automations/change_secret/database/sqlserver/main.yml @@ -1,7 +1,7 @@ - hosts: sqlserver gather_facts: no vars: - ansible_python_interpreter: /opt/py3/bin/python + ansible_python_interpreter: "{{ local_python_interpreter }}" tasks: - name: Test SQLServer connection @@ -64,3 +64,4 @@ name: '{{ jms_asset.spec_info.db_name }}' script: | SELECT @@version + when: check_conn_after_change diff --git a/apps/accounts/automations/change_secret/host/aix/main.yml b/apps/accounts/automations/change_secret/host/aix/main.yml index c61029d74..984b9de8e 100644 --- a/apps/accounts/automations/change_secret/host/aix/main.yml +++ b/apps/accounts/automations/change_secret/host/aix/main.yml @@ -9,7 +9,8 @@ database: passwd key: "{{ account.username }}" register: user_info - ignore_errors: yes # 忽略错误,如果用户不存在时不会导致playbook失败 + failed_when: false + changed_when: false - name: "Add {{ account.username }} user" ansible.builtin.user: @@ -18,10 +19,10 @@ shell: "{{ params.shell if params.shell | length > 0 else omit }}" home: "{{ params.home if params.home | length > 0 else '/home/' + account.username }}" groups: "{{ params.groups if params.groups | length > 0 else omit }}" - append: yes + append: "{{ true if params.groups | length > 0 else false }}" expires: -1 state: present - when: user_info.failed + when: user_info.msg is defined - name: "Set {{ account.username }} sudo setting" ansible.builtin.lineinfile: @@ -31,7 +32,7 @@ line: "{{ account.username + ' ALL=(ALL) NOPASSWD: ' + params.sudo }}" validate: visudo -cf %s when: - - user_info.failed or params.modify_sudo + - user_info.msg is defined or params.modify_sudo - params.sudo - name: "Change {{ account.username }} password" @@ -100,7 +101,7 @@ become_password: "{{ account.become.ansible_password | default('') }}" become_private_key_path: "{{ account.become.ansible_ssh_private_key_file | default(None) }}" old_ssh_version: "{{ jms_asset.old_ssh_version | default(False) }}" - when: account.secret_type == "password" + when: account.secret_type == "password" and check_conn_after_change delegate_to: localhost - name: "Verify {{ account.username }} SSH KEY (paramiko)" @@ -111,5 +112,5 @@ login_private_key_path: "{{ account.private_key_path }}" gateway_args: "{{ jms_asset.ansible_ssh_common_args | default(None) }}" old_ssh_version: "{{ jms_asset.old_ssh_version | default(False) }}" - when: account.secret_type == "ssh_key" + when: account.secret_type == "ssh_key" and check_conn_after_change delegate_to: localhost diff --git a/apps/accounts/automations/change_secret/host/posix/main.yml b/apps/accounts/automations/change_secret/host/posix/main.yml index e36ecdd33..8b991307b 100644 --- a/apps/accounts/automations/change_secret/host/posix/main.yml +++ b/apps/accounts/automations/change_secret/host/posix/main.yml @@ -9,7 +9,8 @@ database: passwd key: "{{ account.username }}" register: user_info - ignore_errors: yes # 忽略错误,如果用户不存在时不会导致playbook失败 + failed_when: false + changed_when: false - name: "Add {{ account.username }} user" ansible.builtin.user: @@ -18,10 +19,10 @@ shell: "{{ params.shell if params.shell | length > 0 else omit }}" home: "{{ params.home if params.home | length > 0 else '/home/' + account.username }}" groups: "{{ params.groups if params.groups | length > 0 else omit }}" - append: yes + append: "{{ true if params.groups | length > 0 else false }}" expires: -1 state: present - when: user_info.failed + when: user_info.msg is defined - name: "Set {{ account.username }} sudo setting" ansible.builtin.lineinfile: @@ -31,7 +32,7 @@ line: "{{ account.username + ' ALL=(ALL) NOPASSWD: ' + params.sudo }}" validate: visudo -cf %s when: - - user_info.failed or params.modify_sudo + - user_info.msg is defined or params.modify_sudo - params.sudo - name: "Change {{ account.username }} password" @@ -100,7 +101,7 @@ become_password: "{{ account.become.ansible_password | default('') }}" become_private_key_path: "{{ account.become.ansible_ssh_private_key_file | default(None) }}" old_ssh_version: "{{ jms_asset.old_ssh_version | default(False) }}" - when: account.secret_type == "password" + when: account.secret_type == "password" and check_conn_after_change delegate_to: localhost - name: "Verify {{ account.username }} SSH KEY (paramiko)" @@ -111,5 +112,5 @@ login_private_key_path: "{{ account.private_key_path }}" gateway_args: "{{ jms_asset.ansible_ssh_common_args | default(None) }}" old_ssh_version: "{{ jms_asset.old_ssh_version | default(False) }}" - when: account.secret_type == "ssh_key" + when: account.secret_type == "ssh_key" and check_conn_after_change delegate_to: localhost diff --git a/apps/accounts/automations/change_secret/host/windows/main.yml b/apps/accounts/automations/change_secret/host/windows/main.yml index a97166fef..c0efa18ea 100644 --- a/apps/accounts/automations/change_secret/host/windows/main.yml +++ b/apps/accounts/automations/change_secret/host/windows/main.yml @@ -4,10 +4,6 @@ - name: Test privileged account ansible.windows.win_ping: -# - name: Print variables -# debug: -# msg: "Username: {{ account.username }}, Password: {{ account.secret }}" - - name: Change password ansible.windows.win_user: fullname: "{{ account.username}}" @@ -28,4 +24,4 @@ vars: ansible_user: "{{ account.username }}" ansible_password: "{{ account.secret }}" - when: account.secret_type == "password" + when: account.secret_type == "password" and check_conn_after_change diff --git a/apps/accounts/automations/change_secret/host/windows_rdp_verify/main.yml b/apps/accounts/automations/change_secret/host/windows_rdp_verify/main.yml index 6c59ef7ef..90004b26e 100644 --- a/apps/accounts/automations/change_secret/host/windows_rdp_verify/main.yml +++ b/apps/accounts/automations/change_secret/host/windows_rdp_verify/main.yml @@ -4,10 +4,6 @@ - name: Test privileged account ansible.windows.win_ping: -# - name: Print variables -# debug: -# msg: "Username: {{ account.username }}, Password: {{ account.secret }}" - - name: Change password ansible.windows.win_user: fullname: "{{ account.username}}" @@ -31,5 +27,5 @@ login_password: "{{ account.secret }}" login_secret_type: "{{ account.secret_type }}" gateway_args: "{{ jms_gateway | default({}) }}" - when: account.secret_type == "password" + when: account.secret_type == "password" and check_conn_after_change delegate_to: localhost diff --git a/apps/accounts/automations/change_secret/manager.py b/apps/accounts/automations/change_secret/manager.py index ab04b348c..64f708773 100644 --- a/apps/accounts/automations/change_secret/manager.py +++ b/apps/accounts/automations/change_secret/manager.py @@ -1,218 +1,71 @@ import os import time -from copy import deepcopy from django.conf import settings -from django.utils import timezone from django.utils.translation import gettext_lazy as _ from xlsxwriter import Workbook -from accounts.const import AutomationTypes, SecretType, SSHKeyStrategy, SecretStrategy, ChangeSecretRecordStatusChoice -from accounts.models import ChangeSecretRecord, BaseAccountQuerySet -from accounts.notifications import ChangeSecretExecutionTaskMsg, ChangeSecretFailedMsg +from accounts.const import ( + AutomationTypes, SecretStrategy, ChangeSecretRecordStatusChoice +) +from accounts.models import ChangeSecretRecord +from accounts.notifications import ChangeSecretExecutionTaskMsg, ChangeSecretReportMsg from accounts.serializers import ChangeSecretRecordBackUpSerializer -from assets.const import HostTypes +from common.decorators import bulk_create_decorator from common.utils import get_logger from common.utils.file import encrypt_and_compress_zip_file from common.utils.timezone import local_now_filename -from users.models import User -from ..base.manager import AccountBasePlaybookManager +from ..base.manager import BaseChangeSecretPushManager from ...utils import SecretGenerator logger = get_logger(__name__) -class ChangeSecretManager(AccountBasePlaybookManager): +class ChangeSecretManager(BaseChangeSecretPushManager): ansible_account_prefer = '' - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.record_map = self.execution.snapshot.get('record_map', {}) - self.secret_type = self.execution.snapshot.get('secret_type') - self.secret_strategy = self.execution.snapshot.get( - 'secret_strategy', SecretStrategy.custom - ) - self.ssh_key_change_strategy = self.execution.snapshot.get( - 'ssh_key_change_strategy', SSHKeyStrategy.add - ) - self.account_ids = self.execution.snapshot['accounts'] - self.name_recorder_mapper = {} # 做个映射,方便后面处理 - @classmethod def method_type(cls): return AutomationTypes.change_secret - def get_ssh_params(self, account, secret, secret_type): - kwargs = {} - if secret_type != SecretType.SSH_KEY: - return kwargs - kwargs['strategy'] = self.ssh_key_change_strategy - kwargs['exclusive'] = 'yes' if kwargs['strategy'] == SSHKeyStrategy.set else 'no' - - if kwargs['strategy'] == SSHKeyStrategy.set_jms: - kwargs['regexp'] = '.*{}$'.format(secret.split()[2].strip()) - return kwargs - - def secret_generator(self, secret_type): - return SecretGenerator( - self.secret_strategy, secret_type, - self.execution.snapshot.get('password_rules') - ) - - def get_secret(self, secret_type): + def get_secret(self, account): if self.secret_strategy == SecretStrategy.custom: - return self.execution.snapshot['secret'] + new_secret = self.execution.snapshot['secret'] else: - return self.secret_generator(secret_type).get_secret() - - def get_accounts(self, privilege_account) -> BaseAccountQuerySet | None: - if not privilege_account: - print('Not privilege account') - return - - asset = privilege_account.asset - accounts = asset.accounts.all() - accounts = accounts.filter(id__in=self.account_ids) - if self.secret_type: - accounts = accounts.filter(secret_type=self.secret_type) - - if settings.CHANGE_AUTH_PLAN_SECURE_MODE_ENABLED: - accounts = accounts.filter(privileged=False).exclude( - username__in=['root', 'administrator', privilege_account.username] + generator = SecretGenerator( + self.secret_strategy, self.secret_type, + self.execution.snapshot.get('password_rules') ) - return accounts + new_secret = generator.get_secret() + return new_secret - def host_callback( - self, host, asset=None, account=None, - automation=None, path_dir=None, **kwargs - ): - host = super().host_callback( - host, asset=asset, account=account, automation=automation, - path_dir=path_dir, **kwargs + def gen_account_inventory(self, account, asset, h, path_dir): + record = self.get_or_create_record(asset, account, h['name']) + new_secret, private_key_path = self.handle_ssh_secret(account.secret_type, record.new_secret, path_dir) + h = self.gen_inventory(h, account, new_secret, private_key_path, asset) + return h + + def get_or_create_record(self, asset, account, name): + asset_account_id = f'{asset.id}-{account.id}' + + if asset_account_id in self.record_map: + record_id = self.record_map[asset_account_id] + recorder = ChangeSecretRecord.objects.filter(id=record_id).first() + else: + new_secret = self.get_secret(account) + recorder = self.create_record(asset, account, new_secret) + + self.name_recorder_mapper[name] = recorder + return recorder + + @bulk_create_decorator(ChangeSecretRecord) + def create_record(self, asset, account, new_secret): + recorder = ChangeSecretRecord( + asset=asset, account=account, execution=self.execution, + old_secret=account.secret, new_secret=new_secret, + comment=f'{account.username}@{asset.address}' ) - if host.get('error'): - return host - - accounts = self.get_accounts(account) - error_msg = _("No pending accounts found") - if not accounts: - print(f'{asset}: {error_msg}') - return [] - - records = [] - inventory_hosts = [] - if asset.type == HostTypes.WINDOWS and self.secret_type == SecretType.SSH_KEY: - print(f'Windows {asset} does not support ssh key push') - return inventory_hosts - - if asset.type == HostTypes.WINDOWS: - accounts = accounts.filter(secret_type=SecretType.PASSWORD) - - host['ssh_params'] = {} - for account in accounts: - h = deepcopy(host) - secret_type = account.secret_type - h['name'] += '(' + account.username + ')' - if self.secret_type is None: - new_secret = account.secret - else: - new_secret = self.get_secret(secret_type) - - if new_secret is None: - print(f'new_secret is None, account: {account}') - continue - - asset_account_id = f'{asset.id}-{account.id}' - if asset_account_id not in self.record_map: - recorder = ChangeSecretRecord( - asset=asset, account=account, execution=self.execution, - old_secret=account.secret, new_secret=new_secret, - comment=f'{account.username}@{asset.address}' - ) - records.append(recorder) - else: - record_id = self.record_map[asset_account_id] - try: - recorder = ChangeSecretRecord.objects.get(id=record_id) - except ChangeSecretRecord.DoesNotExist: - print(f"Record {record_id} not found") - continue - - self.name_recorder_mapper[h['name']] = recorder - - private_key_path = None - if secret_type == SecretType.SSH_KEY: - private_key_path = self.generate_private_key_path(new_secret, path_dir) - new_secret = self.generate_public_key(new_secret) - - h['ssh_params'].update(self.get_ssh_params(account, new_secret, secret_type)) - h['account'] = { - 'name': account.name, - 'username': account.username, - 'secret_type': secret_type, - 'secret': account.escape_jinja2_syntax(new_secret), - 'private_key_path': private_key_path, - 'become': account.get_ansible_become_auth(), - } - if asset.platform.type == 'oracle': - h['account']['mode'] = 'sysdba' if account.privileged else None - inventory_hosts.append(h) - ChangeSecretRecord.objects.bulk_create(records) - return inventory_hosts - - @staticmethod - def require_update_version(account, recorder): - return account.secret != recorder.new_secret - - def on_host_success(self, host, result): - recorder = self.name_recorder_mapper.get(host) - if not recorder: - return - recorder.status = ChangeSecretRecordStatusChoice.success.value - recorder.date_finished = timezone.now() - - account = recorder.account - if not account: - print("Account not found, deleted ?") - return - - version_update_required = self.require_update_version(account, recorder) - account.secret = recorder.new_secret - account.date_updated = timezone.now() - - max_retries = 3 - retry_count = 0 - - while retry_count < max_retries: - try: - recorder.save() - account_update_fields = ['secret', 'date_updated'] - if version_update_required: - account_update_fields.append('version') - account.save(update_fields=account_update_fields) - break - except Exception as e: - retry_count += 1 - if retry_count == max_retries: - self.on_host_error(host, str(e), result) - else: - print(f'retry {retry_count} times for {host} recorder save error: {e}') - time.sleep(1) - - def on_host_error(self, host, error, result): - recorder = self.name_recorder_mapper.get(host) - if not recorder: - return - recorder.status = ChangeSecretRecordStatusChoice.failed.value - recorder.date_finished = timezone.now() - recorder.error = error - try: - recorder.save() - except Exception as e: - print(f"\033[31m Save {host} recorder error: {e} \033[0m\n") - - def on_runner_failed(self, runner, e): - logger.error("Account error: ", e) + return recorder def check_secret(self): if self.secret_strategy == SecretStrategy.custom \ @@ -230,47 +83,39 @@ class ChangeSecretManager(AccountBasePlaybookManager): else: failed += 1 total += 1 - summary = _('Success: %s, Failed: %s, Total: %s') % (succeed, failed, total) return summary - def run(self, *args, **kwargs): - if self.secret_type and not self.check_secret(): - self.execution.status = 'success' - self.execution.date_finished = timezone.now() - self.execution.save() - return - super().run(*args, **kwargs) + def print_summary(self): recorders = list(self.name_recorder_mapper.values()) summary = self.get_summary(recorders) - print(summary, end='') + print('\n\n' + '-' * 80) + plan_execution_end = _('Plan execution end') + print('{} {}\n'.format(plan_execution_end, local_now_filename())) + time_cost = _('Duration') + print('{}: {}s'.format(time_cost, self.duration)) + print(summary) + def send_report_if_need(self, *args, **kwargs): + if self.secret_type and not self.check_secret(): + return + + recorders = list(self.name_recorder_mapper.values()) if self.record_map: return - failed_recorders = [ - r for r in recorders - if r.status == ChangeSecretRecordStatusChoice.failed.value - ] - recipients = self.execution.recipients - recipients = User.objects.filter(id__in=list(recipients.keys())) if not recipients: return - if failed_recorders: - name = self.execution.snapshot.get('name') - execution_id = str(self.execution.id) - _ids = [r.id for r in failed_recorders] - asset_account_errors = ChangeSecretRecord.objects.filter( - id__in=_ids).values_list('asset__name', 'account__username', 'error') - - for user in recipients: - ChangeSecretFailedMsg(name, execution_id, user, asset_account_errors).publish() + context = self.get_report_context() + for user in recipients: + ChangeSecretReportMsg(user, context).publish() if not recorders: return + summary = self.get_summary(recorders) self.send_recorder_mail(recipients, recorders, summary) def send_recorder_mail(self, recipients, recorders, summary): @@ -307,3 +152,6 @@ class ChangeSecretManager(AccountBasePlaybookManager): ws.write_string(row_index, col_index, col_data) wb.close() return True + + def get_report_template(self): + return "accounts/change_secret_report.html" diff --git a/apps/accounts/automations/gather_accounts/__init__.py b/apps/accounts/automations/check_account/__init__.py similarity index 100% rename from apps/accounts/automations/gather_accounts/__init__.py rename to apps/accounts/automations/check_account/__init__.py diff --git a/apps/accounts/automations/check_account/add_to_leak_password.py b/apps/accounts/automations/check_account/add_to_leak_password.py new file mode 100644 index 000000000..ab9384ba5 --- /dev/null +++ b/apps/accounts/automations/check_account/add_to_leak_password.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python +# +import re +import sqlite3 +import sys + + +def is_weak_password(password): + if len(password) < 8: + return True + + # 判断是否只有一种字符类型 + if password.isdigit() or password.isalpha(): + return True + + # 判断是否只包含数字或字母 + if password.islower() or password.isupper(): + return True + + # 判断是否包含常见弱密码 + common_passwords = ["123456", "password", "12345678", "qwerty", "abc123"] + if password.lower() in common_passwords: + return True + + # 正则表达式判断字符多样性(数字、字母、特殊字符) + if ( + not re.search(r"[A-Za-z]", password) + or not re.search(r"[0-9]", password) + or not re.search(r"[\W_]", password) + ): + return True + return False + + +def parse_it(fname): + count = 0 + lines = [] + with open(fname, 'rb') as f: + for line in f: + try: + line = line.decode().strip() + except UnicodeDecodeError: + continue + + if len(line) > 32: + continue + + if is_weak_password(line): + continue + + lines.append(line) + count += 0 + print(line) + return lines + + +def insert_to_db(lines): + conn = sqlite3.connect('./leak_passwords.db') + cursor = conn.cursor() + create_table_sql = ''' + CREATE TABLE IF NOT EXISTS passwords ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + password CHAR(32) + ) + ''' + create_index_sql = 'CREATE INDEX IF NOT EXISTS idx_password ON passwords(password)' + cursor.execute(create_table_sql) + cursor.execute(create_index_sql) + + for line in lines: + cursor.execute('INSERT INTO passwords (password) VALUES (?)', [line]) + conn.commit() + + +if __name__ == '__main__': + filename = sys.argv[1] + lines = parse_it(filename) + insert_to_db(lines) diff --git a/apps/accounts/automations/check_account/leak_passwords.db b/apps/accounts/automations/check_account/leak_passwords.db new file mode 100644 index 000000000..7f2c72ae5 --- /dev/null +++ b/apps/accounts/automations/check_account/leak_passwords.db @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a2805a0264fc07ae597704841ab060edef8bf74654f525bc778cb9195d8cad0e +size 2547712 diff --git a/apps/accounts/automations/check_account/manager.py b/apps/accounts/automations/check_account/manager.py new file mode 100644 index 000000000..34640adf8 --- /dev/null +++ b/apps/accounts/automations/check_account/manager.py @@ -0,0 +1,284 @@ +import hashlib +import os +import re +import sqlite3 +import uuid + +from django.conf import settings +from django.utils import timezone + +from accounts.models import Account, AccountRisk, RiskChoice +from assets.automations.base.manager import BaseManager +from common.const import ConfirmOrIgnore +from common.decorators import bulk_create_decorator, bulk_update_decorator + + +@bulk_create_decorator(AccountRisk) +def create_risk(data): + return AccountRisk(**data) + + +@bulk_update_decorator(AccountRisk, update_fields=["details", "status"]) +def update_risk(risk): + return risk + + +class BaseCheckHandler: + risk = '' + + def __init__(self, assets): + self.assets = assets + + def check(self, account): + pass + + def clean(self): + pass + + +class CheckSecretHandler(BaseCheckHandler): + risk = RiskChoice.weak_password + + @staticmethod + def is_weak_password(password): + # 判断密码长度 + if len(password) < 8: + return True + + # 判断是否只有一种字符类型 + if password.isdigit() or password.isalpha(): + return True + + # 判断是否只包含数字或字母 + if password.islower() or password.isupper(): + return True + + # 判断是否包含常见弱密码 + common_passwords = ["123456", "password", "12345678", "qwerty", "abc123"] + if password.lower() in common_passwords: + return True + + # 正则表达式判断字符多样性(数字、字母、特殊字符) + if ( + not re.search(r"[A-Za-z]", password) + or not re.search(r"[0-9]", password) + or not re.search(r"[\W_]", password) + ): + return True + return False + + def check(self, account): + if not account.secret: + return False + return self.is_weak_password(account.secret) + + +class CheckRepeatHandler(BaseCheckHandler): + risk = RiskChoice.repeated_password + + def __init__(self, assets): + super().__init__(assets) + self.path, self.conn, self.cursor = self.init_repeat_check_db() + self.add_password_for_check_repeat() + + @staticmethod + def init_repeat_check_db(): + path = os.path.join('/tmp', 'accounts_' + str(uuid.uuid4()) + '.db') + sql = """ + CREATE TABLE IF NOT EXISTS accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + digest CHAR(32) + ) + """ + index = "CREATE INDEX IF NOT EXISTS idx_digest ON accounts(digest)" + conn = sqlite3.connect(path) + cursor = conn.cursor() + cursor.execute(sql) + cursor.execute(index) + return path, conn, cursor + + def check(self, account): + if not account.secret: + return False + + digest = self.digest(account.secret) + sql = 'SELECT COUNT(*) FROM accounts WHERE digest = ?' + self.cursor.execute(sql, [digest]) + result = self.cursor.fetchone() + if not result: + return False + return result[0] > 1 + + @staticmethod + def digest(secret): + return hashlib.md5(secret.encode()).hexdigest() + + def add_password_for_check_repeat(self): + accounts = Account.objects.all().only('id', '_secret', 'secret_type') + sql = "INSERT INTO accounts (digest) VALUES (?)" + + for account in accounts: + secret = account.secret + if not secret: + continue + digest = self.digest(secret) + self.cursor.execute(sql, [digest]) + self.conn.commit() + + def clean(self): + self.cursor.close() + self.conn.close() + os.remove(self.path) + + +class CheckLeakHandler(BaseCheckHandler): + risk = RiskChoice.leaked_password + + def __init__(self, *args): + super().__init__(*args) + self.conn, self.cursor = self.init_leak_password_db() + + @staticmethod + def init_leak_password_db(): + db_path = os.path.join( + settings.APPS_DIR, 'accounts', 'automations', + 'check_account', 'leak_passwords.db' + ) + + if settings.LEAK_PASSWORD_DB_PATH and os.path.isfile(settings.LEAK_PASSWORD_DB_PATH): + db_path = settings.LEAK_PASSWORD_DB_PATH + + db_conn = sqlite3.connect(db_path) + db_cursor = db_conn.cursor() + return db_conn, db_cursor + + def check(self, account): + if not account.secret: + return False + + sql = 'SELECT 1 FROM passwords WHERE password = ? LIMIT 1' + self.cursor.execute(sql, (account.secret,)) + leak = self.cursor.fetchone() is not None + return leak + + def clean(self): + self.cursor.close() + self.conn.close() + + +class CheckAccountManager(BaseManager): + batch_size = 100 + tmpl = 'Checked the status of account %s: %s' + + def __init__(self, execution): + super().__init__(execution) + self.assets = [] + self.batch_risks = [] + self.handlers = [] + + def add_risk(self, risk, account): + self.summary[risk] += 1 + self.result[risk].append({ + 'asset': str(account.asset), 'username': account.username, + }) + risk_obj = {'account': account, 'risk': risk} + self.batch_risks.append(risk_obj) + + def commit_risks(self, assets): + account_risks = AccountRisk.objects.filter(asset__in=assets) + ori_risk_map = {} + + for risk in account_risks: + key = f'{risk.account_id}_{risk.risk}' + ori_risk_map[key] = risk + + now = timezone.now().isoformat() + for d in self.batch_risks: + account = d["account"] + key = f'{account.id}_{d["risk"]}' + origin_risk = ori_risk_map.get(key) + + if origin_risk and origin_risk.status != ConfirmOrIgnore.pending: + details = origin_risk.details or [] + details.append({"datetime": now, 'type': 'refind'}) + + if len(details) > 10: + details = [*details[:5], *details[-5:]] + + origin_risk.details = details + origin_risk.status = ConfirmOrIgnore.pending + update_risk(origin_risk) + else: + create_risk({ + "account": account, + "asset": account.asset, + "username": account.username, + "risk": d["risk"], + "details": [{"datetime": now, 'type': 'init'}], + }) + + def pre_run(self): + super().pre_run() + self.assets = self.execution.get_all_assets() + self.execution.date_start = timezone.now() + self.execution.save(update_fields=["date_start"]) + + def batch_check(self, handler): + print("Engine: {}".format(handler.__class__.__name__)) + for i in range(0, len(self.assets), self.batch_size): + _assets = self.assets[i: i + self.batch_size] + accounts = Account.objects.filter(asset__in=_assets) + + print("Start to check accounts: {}".format(len(accounts))) + + for account in accounts: + error = handler.check(account) + msg = handler.risk if error else 'ok' + + print("Check: {} => {}".format(account, msg)) + if not error: + continue + self.add_risk(handler.risk, account) + self.commit_risks(_assets) + + def do_run(self, *args, **kwargs): + for engine in self.execution.snapshot.get("engines", []): + if engine == "check_account_secret": + handler = CheckSecretHandler(self.assets) + elif engine == "check_account_repeat": + handler = CheckRepeatHandler(self.assets) + elif engine == "check_account_leak": + handler = CheckLeakHandler(self.assets) + else: + print("Unknown engine: {}".format(engine)) + continue + + self.handlers.append(handler) + self.batch_check(handler) + + def post_run(self): + super().post_run() + for handler in self.handlers: + handler.clean() + + def get_report_subject(self): + return "Check account report of %s" % self.execution.id + + def get_report_template(self): + return "accounts/check_account_report.html" + + def print_summary(self): + tmpl = ( + "\n---\nSummary: \nok: %s, weak password: %s, leaked password: %s, " + "repeated password: %s, no secret: %s, using time: %ss" + % ( + self.summary["ok"], + self.summary[RiskChoice.weak_password], + self.summary[RiskChoice.leaked_password], + self.summary[RiskChoice.repeated_password], + + self.summary["no_secret"], + int(self.duration), + ) + ) + print(tmpl) diff --git a/apps/accounts/automations/endpoint.py b/apps/accounts/automations/endpoint.py index f045858a7..84323efad 100644 --- a/apps/accounts/automations/endpoint.py +++ b/apps/accounts/automations/endpoint.py @@ -1,6 +1,7 @@ from .backup_account.manager import AccountBackupManager from .change_secret.manager import ChangeSecretManager -from .gather_accounts.manager import GatherAccountsManager +from .check_account.manager import CheckAccountManager +from .gather_account.manager import GatherAccountsManager from .push_account.manager import PushAccountManager from .remove_account.manager import RemoveAccountManager from .verify_account.manager import VerifyAccountManager @@ -16,8 +17,8 @@ class ExecutionManager: AutomationTypes.remove_account: RemoveAccountManager, AutomationTypes.gather_accounts: GatherAccountsManager, AutomationTypes.verify_gateway_account: VerifyGatewayAccountManager, - # TODO 后期迁移到自动化策略中 - 'backup_account': AccountBackupManager, + AutomationTypes.check_account: CheckAccountManager, + AutomationTypes.backup_account: AccountBackupManager, } def __init__(self, execution): @@ -26,3 +27,6 @@ class ExecutionManager: def run(self, *args, **kwargs): return self._runner.run(*args, **kwargs) + + def __getattr__(self, item): + return getattr(self._runner, item) diff --git a/apps/static/css/plugins/ztree/awesomeStyle/fa.css b/apps/accounts/automations/gather_account/__init__.py similarity index 100% rename from apps/static/css/plugins/ztree/awesomeStyle/fa.css rename to apps/accounts/automations/gather_account/__init__.py diff --git a/apps/accounts/automations/gather_accounts/database/mongodb/main.yml b/apps/accounts/automations/gather_account/database/mongodb/main.yml similarity index 90% rename from apps/accounts/automations/gather_accounts/database/mongodb/main.yml rename to apps/accounts/automations/gather_account/database/mongodb/main.yml index 9751f4286..a82bcc617 100644 --- a/apps/accounts/automations/gather_accounts/database/mongodb/main.yml +++ b/apps/accounts/automations/gather_account/database/mongodb/main.yml @@ -1,7 +1,7 @@ - hosts: mongodb gather_facts: no vars: - ansible_python_interpreter: /opt/py3/bin/python + ansible_python_interpreter: "{{ local_python_interpreter }}" tasks: - name: Get info @@ -15,7 +15,7 @@ ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert | default('') }}" ssl_certfile: "{{ jms_asset.secret_info.client_key | default('') }}" connection_options: - - tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert}}" + - tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert }}" filter: users register: db_info diff --git a/apps/accounts/automations/gather_accounts/database/mongodb/manifest.yml b/apps/accounts/automations/gather_account/database/mongodb/manifest.yml similarity index 100% rename from apps/accounts/automations/gather_accounts/database/mongodb/manifest.yml rename to apps/accounts/automations/gather_account/database/mongodb/manifest.yml diff --git a/apps/accounts/automations/gather_accounts/database/mysql/main.yml b/apps/accounts/automations/gather_account/database/mysql/main.yml similarity index 94% rename from apps/accounts/automations/gather_accounts/database/mysql/main.yml rename to apps/accounts/automations/gather_account/database/mysql/main.yml index 37e446502..9b12f05fd 100644 --- a/apps/accounts/automations/gather_accounts/database/mysql/main.yml +++ b/apps/accounts/automations/gather_account/database/mysql/main.yml @@ -1,7 +1,7 @@ - hosts: mysql gather_facts: no vars: - ansible_python_interpreter: /opt/py3/bin/python + ansible_python_interpreter: "{{ local_python_interpreter }}" check_ssl: "{{ jms_asset.spec_info.use_ssl and not jms_asset.spec_info.allow_invalid_cert }}" ca_cert: "{{ jms_asset.secret_info.ca_cert | default('') }}" ssl_cert: "{{ jms_asset.secret_info.client_cert | default('') }}" diff --git a/apps/accounts/automations/gather_accounts/database/mysql/manifest.yml b/apps/accounts/automations/gather_account/database/mysql/manifest.yml similarity index 100% rename from apps/accounts/automations/gather_accounts/database/mysql/manifest.yml rename to apps/accounts/automations/gather_account/database/mysql/manifest.yml diff --git a/apps/accounts/automations/gather_accounts/database/oracle/main.yml b/apps/accounts/automations/gather_account/database/oracle/main.yml similarity index 89% rename from apps/accounts/automations/gather_accounts/database/oracle/main.yml rename to apps/accounts/automations/gather_account/database/oracle/main.yml index 37d381f7d..dc2b5a701 100644 --- a/apps/accounts/automations/gather_accounts/database/oracle/main.yml +++ b/apps/accounts/automations/gather_account/database/oracle/main.yml @@ -1,7 +1,7 @@ - hosts: oralce gather_facts: no vars: - ansible_python_interpreter: /opt/py3/bin/python + ansible_python_interpreter: "{{ local_python_interpreter }}" tasks: - name: Get info diff --git a/apps/accounts/automations/gather_accounts/database/oracle/manifest.yml b/apps/accounts/automations/gather_account/database/oracle/manifest.yml similarity index 100% rename from apps/accounts/automations/gather_accounts/database/oracle/manifest.yml rename to apps/accounts/automations/gather_account/database/oracle/manifest.yml diff --git a/apps/accounts/automations/gather_accounts/database/postgresql/main.yml b/apps/accounts/automations/gather_account/database/postgresql/main.yml similarity index 94% rename from apps/accounts/automations/gather_accounts/database/postgresql/main.yml rename to apps/accounts/automations/gather_account/database/postgresql/main.yml index 0d2093aab..ee8e3daf8 100644 --- a/apps/accounts/automations/gather_accounts/database/postgresql/main.yml +++ b/apps/accounts/automations/gather_account/database/postgresql/main.yml @@ -1,7 +1,7 @@ - hosts: postgresql gather_facts: no vars: - ansible_python_interpreter: /opt/py3/bin/python + ansible_python_interpreter: "{{ local_python_interpreter }}" check_ssl: "{{ jms_asset.spec_info.use_ssl }}" ca_cert: "{{ jms_asset.secret_info.ca_cert | default('') }}" ssl_cert: "{{ jms_asset.secret_info.client_cert | default('') }}" diff --git a/apps/accounts/automations/gather_accounts/database/postgresql/manifest.yml b/apps/accounts/automations/gather_account/database/postgresql/manifest.yml similarity index 100% rename from apps/accounts/automations/gather_accounts/database/postgresql/manifest.yml rename to apps/accounts/automations/gather_account/database/postgresql/manifest.yml diff --git a/apps/accounts/automations/gather_account/database/sqlserver/main.yml b/apps/accounts/automations/gather_account/database/sqlserver/main.yml new file mode 100644 index 000000000..ce8a20e0d --- /dev/null +++ b/apps/accounts/automations/gather_account/database/sqlserver/main.yml @@ -0,0 +1,43 @@ +- hosts: sqlserver + gather_facts: no + vars: + ansible_python_interpreter: "{{ local_python_interpreter }}" + + tasks: + - name: Test SQLServer connection + community.general.mssql_script: + login_user: "{{ jms_account.username }}" + login_password: "{{ jms_account.secret }}" + login_host: "{{ jms_asset.address }}" + login_port: "{{ jms_asset.port }}" + name: '{{ jms_asset.spec_info.db_name }}' + script: | + SELECT + l.name, + l.modify_date, + l.is_disabled, + l.create_date, + l.default_database_name, + LOGINPROPERTY(name, 'DaysUntilExpiration') AS days_until_expiration, + MAX(s.login_time) AS last_login_time + FROM + sys.sql_logins l + LEFT JOIN + sys.dm_exec_sessions s + ON + l.name = s.login_name + WHERE + s.is_user_process = 1 OR s.login_name IS NULL + GROUP BY + l.name, l.create_date, l.modify_date, l.is_disabled, l.default_database_name + ORDER BY + last_login_time DESC; + output: dict + register: db_info + + - name: Define info by set_fact + set_fact: + info: "{{ db_info.query_results_dict }}" + + - debug: + var: info diff --git a/apps/accounts/automations/gather_account/database/sqlserver/manifest.yml b/apps/accounts/automations/gather_account/database/sqlserver/manifest.yml new file mode 100644 index 000000000..aaf2891c7 --- /dev/null +++ b/apps/accounts/automations/gather_account/database/sqlserver/manifest.yml @@ -0,0 +1,10 @@ +id: gather_accounts_sqlserver +name: "{{ 'SQLServer account gather' | trans }}" +category: database +type: + - sqlserver +method: gather_accounts +i18n: + SQLServer account gather: + zh: SQLServer 账号收集 + ja: SQLServer アカウントの収集 \ No newline at end of file diff --git a/apps/accounts/automations/gather_account/filter.py b/apps/accounts/automations/gather_account/filter.py new file mode 100644 index 000000000..41bfc4938 --- /dev/null +++ b/apps/accounts/automations/gather_account/filter.py @@ -0,0 +1,248 @@ +from django.utils import timezone +from datetime import datetime + +__all__ = ['GatherAccountsFilter'] + + +def parse_date(date_str, default=None): + if not date_str: + return default + if date_str in ['Never', 'null']: + return default + formats = [ + '%Y/%m/%d %H:%M:%S', + '%Y-%m-%dT%H:%M:%S', + '%d-%m-%Y %H:%M:%S', + '%Y/%m/%d', + '%d-%m-%Y', + ] + for fmt in formats: + try: + dt = datetime.strptime(date_str, fmt) + return timezone.make_aware(dt, timezone.get_current_timezone()) + except ValueError: + continue + return default + + +# TODO 后期会挪到 playbook 中 +class GatherAccountsFilter: + def __init__(self, tp): + self.tp = tp + + @staticmethod + def mysql_filter(info): + result = {} + for host, user_dict in info.items(): + for username, user_info in user_dict.items(): + password_last_changed = parse_date(user_info.get('password_last_changed')) + password_lifetime = user_info.get('password_lifetime') + user = { + 'username': username, + 'date_password_change': password_last_changed, + 'date_password_expired': password_last_changed + timezone.timedelta( + days=password_lifetime) if password_last_changed and password_lifetime else None, + 'date_last_login': None, + 'groups': '', + } + result[username] = user + return result + + @staticmethod + def postgresql_filter(info): + result = {} + for username, user_info in info.items(): + user = { + 'username': username, + 'date_password_change': None, + 'date_password_expired': parse_date(user_info.get('valid_until')), + 'date_last_login': None, + 'groups': '', + } + detail = { + 'can_login': user_info.get('canlogin'), + 'superuser': user_info.get('superuser'), + } + user['detail'] = detail + result[username] = user + return result + + @staticmethod + def sqlserver_filter(info): + if not info: + return {} + result = {} + for user_info in info[0][0]: + days_until_expiration = user_info.get('days_until_expiration') + date_password_expired = timezone.now() + timezone.timedelta( + days=int(days_until_expiration)) if days_until_expiration else None + user = { + 'username': user_info.get('name', ''), + 'date_password_change': parse_date(user_info.get('modify_date')), + 'date_password_expired': date_password_expired, + 'date_last_login': parse_date(user_info.get('last_login_time')), + 'groups': '', + } + detail = { + 'create_date': user_info.get('create_date', ''), + 'is_disabled': user_info.get('is_disabled', ''), + 'default_database_name': user_info.get('default_database_name', ''), + } + user['detail'] = detail + result[user['username']] = user + return result + + @staticmethod + def oracle_filter(info): + result = {} + for default_tablespace, users in info.items(): + for username, user_info in users.items(): + user = { + 'username': username, + 'date_password_change': parse_date(user_info.get('password_change_date')), + 'date_password_expired': parse_date(user_info.get('expiry_date')), + 'date_last_login': parse_date(user_info.get('last_login')), + 'groups': '', + } + detail = { + 'uid': user_info.get('user_id', ''), + 'create_date': user_info.get('created', ''), + 'account_status': user_info.get('account_status', ''), + 'default_tablespace': default_tablespace, + 'roles': user_info.get('roles', []), + 'privileges': user_info.get('privileges', []), + } + user['detail'] = detail + result[user['username']] = user + return result + + @staticmethod + def posix_filter(info): + user_groups = info.pop('user_groups', []) + username_groups = {} + for line in user_groups: + if ':' not in line: + continue + username, groups = line.split(':', 1) + username_groups[username.strip()] = groups.strip() + + user_sudo = info.pop('user_sudo', []) + username_sudo = {} + for line in user_sudo: + if ':' not in line: + continue + username, sudo = line.split(':', 1) + if not sudo.strip(): + continue + username_sudo[username.strip()] = sudo.strip() + + last_login = info.pop('last_login', '') + user_last_login = {} + for line in last_login: + if not line.strip() or ' ' not in line: + continue + username, login = line.split(' ', 1) + user_last_login[username] = login.split() + + user_authorized = info.pop('user_authorized', []) + username_authorized = {} + for line in user_authorized: + if ':' not in line: + continue + username, authorized = line.split(':', 1) + username_authorized[username.strip()] = authorized.strip() + + passwd_date = info.pop('passwd_date', []) + username_password_date = {} + for line in passwd_date: + if ':' not in line: + continue + username, password_date = line.split(':', 1) + username_password_date[username.strip()] = password_date.strip().split() + + result = {} + users = info.pop('users', '') + + for username in users: + if not username: + continue + user = dict() + + login = user_last_login.get(username) or '' + if login and len(login) == 3: + user['address_last_login'] = login[0][:32] + try: + login_date = timezone.datetime.fromisoformat(login[1]) + user['date_last_login'] = login_date + except ValueError: + pass + + start_date = timezone.make_aware(timezone.datetime(1970, 1, 1)) + _password_date = username_password_date.get(username) or '' + if _password_date and len(_password_date) == 2: + if _password_date[0] and _password_date[0] != '0': + user['date_password_change'] = start_date + timezone.timedelta(days=int(_password_date[0])) + if _password_date[1] and _password_date[1] != '0': + user['date_password_expired'] = start_date + timezone.timedelta(days=int(_password_date[1])) + detail = { + 'groups': username_groups.get(username) or '', + 'sudoers': username_sudo.get(username) or '', + 'authorized_keys': username_authorized.get(username) or '' + } + user['detail'] = detail + result[username] = user + return result + + @staticmethod + def windows_filter(info): + result = {} + for user_details in info['user_details']: + user_info = {} + lines = user_details['stdout_lines'] + for line in lines: + if not line.strip(): + continue + parts = line.split(' ', 1) + if len(parts) == 2: + key, value = parts + user_info[key.strip()] = value.strip() + detail = {'groups': user_info.get('Global Group memberships', ''), } + user = { + 'username': user_info.get('User name', ''), + 'date_password_change': parse_date(user_info.get('Password last set', '')), + 'date_password_expired': parse_date(user_info.get('Password expires', '')), + 'date_last_login': parse_date(user_info.get('Last logon', '')), + 'groups': detail, + } + result[user['username']] = user + return result + + @staticmethod + def mongodb_filter(info): + result = {} + for db, users in info.items(): + for username, user_info in users.items(): + user = { + 'username': username, + 'date_password_change': None, + 'date_password_expired': None, + 'date_last_login': None, + 'groups': '', + } + result['detail'] = {'db': db, 'roles': user_info.get('roles', [])} + result[username] = user + return result + + def run(self, method_id_meta_mapper, info): + run_method_name = None + for k, v in method_id_meta_mapper.items(): + if self.tp not in v['type']: + continue + run_method_name = k.replace(f'{v["method"]}_', '') + + if not run_method_name: + return info + + if hasattr(self, f'{run_method_name}_filter'): + return getattr(self, f'{run_method_name}_filter')(info) + return info diff --git a/apps/accounts/automations/gather_account/host/posix/main.yml b/apps/accounts/automations/gather_account/host/posix/main.yml new file mode 100644 index 000000000..59f09d948 --- /dev/null +++ b/apps/accounts/automations/gather_account/host/posix/main.yml @@ -0,0 +1,61 @@ +- hosts: demo + gather_facts: no + tasks: + - name: Get users + ansible.builtin.shell: + cmd: > + getent passwd | awk -F: '$7 !~ /(false|nologin|true|sync)$/' | grep -v '^$' | awk -F":" '{ print $1 }' + register: users + + - name: Gather posix account last login + ansible.builtin.shell: | + for user in {{ users.stdout_lines | join(" ") }}; do + last -i --time-format iso -n 1 ${user} | awk '{ print $1,$3,$4, $NF }' | head -1 | grep -v ^$ + done + register: last_login + + - name: Get user password change date and expiry + ansible.builtin.shell: | + for user in {{ users.stdout_lines | join(" ") }}; do + k=$(getent shadow $user | awk -F: '{ print $3, $5 }') + echo "$user:$k" + done + register: passwd_date + + - name: Get user groups + ansible.builtin.shell: | + for user in {{ users.stdout_lines | join(" ") }}; do + echo "$(groups $user)" | sed 's@ : @:@g' + done + register: user_groups + + - name: Get sudoers + ansible.builtin.shell: | + for user in {{ users.stdout_lines | join(" ") }}; do + echo "$user: $(grep "^$user " /etc/sudoers | tr '\n' ';' || echo '')" + done + register: user_sudo + + - name: Get authorized keys + ansible.builtin.shell: | + for user in {{ users.stdout_lines | join(" ") }}; do + home=$(getent passwd $user | cut -d: -f6) + echo -n "$user:" + if [[ -f ${home}/.ssh/authorized_keys ]]; then + cat ${home}/.ssh/authorized_keys | tr '\n' ';' + fi + echo + done + register: user_authorized + + - set_fact: + info: + users: "{{ users.stdout_lines }}" + last_login: "{{ last_login.stdout_lines }}" + user_groups: "{{ user_groups.stdout_lines }}" + user_sudo: "{{ user_sudo.stdout_lines }}" + user_authorized: "{{ user_authorized.stdout_lines }}" + passwd_date: "{{ passwd_date.stdout_lines }}" + + - debug: + var: info diff --git a/apps/accounts/automations/gather_accounts/host/posix/manifest.yml b/apps/accounts/automations/gather_account/host/posix/manifest.yml similarity index 100% rename from apps/accounts/automations/gather_accounts/host/posix/manifest.yml rename to apps/accounts/automations/gather_account/host/posix/manifest.yml diff --git a/apps/accounts/automations/gather_account/host/windows/main.yml b/apps/accounts/automations/gather_account/host/windows/main.yml new file mode 100644 index 000000000..6d545701a --- /dev/null +++ b/apps/accounts/automations/gather_account/host/windows/main.yml @@ -0,0 +1,32 @@ +- hosts: demo + gather_facts: no + tasks: + - name: Run net user command to get all users + win_shell: net user + register: user_list_output + + - name: Parse all users from net user command + set_fact: + all_users: >- + {%- set users = [] -%} + {%- for line in user_list_output.stdout_lines -%} + {%- if loop.index > 4 and line.strip() != "" and not line.startswith("The command completed") -%} + {%- for user in line.split() -%} + {%- set _ = users.append(user) -%} + {%- endfor -%} + {%- endif -%} + {%- endfor -%} + {{ users }} + + - name: Run net user command for each user to get details + win_shell: net user {{ item }} + loop: "{{ all_users }}" + register: user_details + ignore_errors: yes + + - set_fact: + info: + user_details: "{{ user_details.results }}" + + - debug: + var: info diff --git a/apps/accounts/automations/gather_accounts/host/windows/manifest.yml b/apps/accounts/automations/gather_account/host/windows/manifest.yml similarity index 100% rename from apps/accounts/automations/gather_accounts/host/windows/manifest.yml rename to apps/accounts/automations/gather_account/host/windows/manifest.yml diff --git a/apps/accounts/automations/gather_account/manager.py b/apps/accounts/automations/gather_account/manager.py new file mode 100644 index 000000000..ad44de481 --- /dev/null +++ b/apps/accounts/automations/gather_account/manager.py @@ -0,0 +1,385 @@ +import time +from collections import defaultdict + +from django.utils import timezone + +from accounts.const import AutomationTypes +from accounts.models import GatheredAccount, Account, AccountRisk, RiskChoice +from common.const import ConfirmOrIgnore +from common.decorators import bulk_create_decorator, bulk_update_decorator +from common.utils import get_logger +from common.utils.strings import get_text_diff +from orgs.utils import tmp_to_org +from .filter import GatherAccountsFilter +from ..base.manager import AccountBasePlaybookManager + +logger = get_logger(__name__) + +risk_items = [ + "authorized_keys", + "sudoers", + "groups", +] +common_risk_items = [ + "address_last_login", + "date_last_login", + "date_password_change", + "date_password_expired", + "detail" +] +diff_items = risk_items + common_risk_items + + +def format_datetime(value): + if isinstance(value, timezone.datetime): + return value.strftime("%Y-%m-%d %H:%M:%S") + return value + + +def get_items_diff(ori_account, d): + if hasattr(ori_account, "_diff"): + return ori_account._diff + + diff = {} + for item in diff_items: + get_item_diff(item, ori_account, d, diff) + ori_account._diff = diff + return diff + + +def get_item_diff(item, ori_account, d, diff): + detail = getattr(ori_account, 'detail', {}) + new_detail = d.get('detail', {}) + ori = getattr(ori_account, item, None) or detail.get(item) + new = d.get(item, "") or new_detail.get(item) + if not ori and not new: + return + + ori = format_datetime(ori) + new = format_datetime(new) + + if new != ori: + diff[item] = get_text_diff(str(ori), str(new)) + + +class AnalyseAccountRisk: + long_time = timezone.timedelta(days=90) + datetime_check_items = [ + {"field": "date_last_login", "risk": "long_time_no_login", "delta": long_time}, + { + "field": "date_password_change", + "risk": RiskChoice.long_time_password, + "delta": long_time, + }, + { + "field": "date_password_expired", + "risk": "password_expired", + "delta": timezone.timedelta(seconds=1), + }, + ] + + def __init__(self, check_risk=True): + self.check_risk = check_risk + self.now = timezone.now() + self.pending_add_risks = [] + + def _analyse_item_changed(self, ori_ga, d): + diff = get_items_diff(ori_ga, d) + if not diff: + return + + risks = [] + for k, v in diff.items(): + if k not in risk_items: + continue + risks.append( + dict( + asset_id=str(ori_ga.asset_id), + username=ori_ga.username, + gathered_account=ori_ga, + risk=k + "_changed", + detail={"diff": v}, + ) + ) + self.save_or_update_risks(risks) + + def _analyse_datetime_changed(self, ori_account, d, asset, username): + basic = {"asset_id": str(asset.id), "username": username} + + risks = [] + for item in self.datetime_check_items: + field = item["field"] + risk = item["risk"] + delta = item["delta"] + + date = d.get(field) + if not date: + continue + + pre_date = ori_account and getattr(ori_account, field) + if pre_date == date: + continue + + if date and date < timezone.now() - delta: + risks.append( + dict(**basic, risk=risk, detail={"date": date.isoformat()}) + ) + + self.save_or_update_risks(risks) + + def save_or_update_risks(self, risks): + # 提前取出来,避免每次都查数据库 + asset_ids = {r["asset_id"] for r in risks} + assets_risks = AccountRisk.objects.filter(asset_id__in=asset_ids) + assets_risks = {f"{r.asset_id}_{r.username}_{r.risk}": r for r in assets_risks} + + for d in risks: + detail = d.pop("detail", {}) + detail["datetime"] = self.now.isoformat() + key = f"{d['asset_id']}_{d['username']}_{d['risk']}" + found = assets_risks.get(key) + + if not found: + self._create_risk(dict(**d, details=[detail])) + continue + + found.details.append(detail) + self._update_risk(found) + + @bulk_create_decorator(AccountRisk) + def _create_risk(self, data): + return AccountRisk(**data) + + @bulk_update_decorator(AccountRisk, update_fields=["details"]) + def _update_risk(self, account): + return account + + def analyse_risk(self, asset, ga, d, sys_found): + if not self.check_risk: + return + + basic = {"asset": asset, "username": d["username"], 'gathered_account': ga.id} + if ga: + self._analyse_item_changed(ga, d) + elif not sys_found: + self._create_risk( + dict( + **basic, + risk=RiskChoice.new_found, + details=[{"datetime": self.now.isoformat()}], + ) + ) + self._analyse_datetime_changed(ga, d, asset, d["username"]) + + +class GatherAccountsManager(AccountBasePlaybookManager): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.host_asset_mapper = {} + self.asset_account_info = {} + self.asset_usernames_mapper = defaultdict(set) + self.ori_asset_usernames = defaultdict(set) + self.ori_gathered_usernames = defaultdict(set) + self.ori_gathered_accounts_mapper = dict() + self.is_sync_account = self.execution.snapshot.get("is_sync_account") + self.check_risk = self.execution.snapshot.get("check_risk", False) + + @classmethod + def method_type(cls): + return AutomationTypes.gather_accounts + + def host_callback(self, host, asset=None, **kwargs): + super().host_callback(host, asset=asset, **kwargs) + self.host_asset_mapper[host["name"]] = asset + return host + + def _filter_success_result(self, tp, result): + result = GatherAccountsFilter(tp).run(self.method_id_meta_mapper, result) + return result + + @staticmethod + def _get_nested_info(data, *keys): + for key in keys: + data = data.get(key, {}) + if not data: + break + return data + + def _collect_asset_account_info(self, asset, info): + result = self._filter_success_result(asset.type, info) + accounts = [] + for username, info in result.items(): + self.asset_usernames_mapper[str(asset.id)].add(username) + + d = {"asset": asset, "username": username, "remote_present": True, **info} + accounts.append(d) + self.asset_account_info[asset] = accounts + + def on_host_success(self, host, result): + super().on_host_success(host, result) + info = self._get_nested_info(result, "debug", "res", "info") + asset = self.host_asset_mapper.get(host) + + if asset and info: + self._collect_asset_account_info(asset, info) + else: + print(f"\033[31m Not found {host} info \033[0m\n") + + def prefetch_origin_account_usernames(self): + """ + 提起查出来,避免每次 sql 查询 + :return: + """ + assets = self.asset_usernames_mapper.keys() + accounts = Account.objects.filter(asset__in=assets).values_list( + "asset", "username" + ) + + for asset_id, username in accounts: + self.ori_asset_usernames[str(asset_id)].add(username) + + ga_accounts = GatheredAccount.objects.filter(asset__in=assets) + for account in ga_accounts: + self.ori_gathered_usernames[str(account.asset_id)].add(account.username) + key = "{}_{}".format(account.asset_id, account.username) + self.ori_gathered_accounts_mapper[key] = account + + def update_gather_accounts_status(self, asset): + """ + 远端账号,收集中的账号,vault 中的账号。 + 要根据账号新增见啥,标识 收集账号的状态, 让管理员关注 + + 远端账号 -> 收集账号 -> 特权账号 + """ + remote_users = self.asset_usernames_mapper[str(asset.id)] + ori_users = self.ori_asset_usernames[str(asset.id)] + ori_ga_users = self.ori_gathered_usernames[str(asset.id)] + + queryset = GatheredAccount.objects.filter(asset=asset).exclude( + status=ConfirmOrIgnore.ignored + ) + + # 远端账号 比 收集账号多的 + # 新增创建,不用处理状态 + new_found_users = remote_users - ori_ga_users + if new_found_users: + self.summary["new_accounts"] += len(new_found_users) + for username in new_found_users: + self.result["new_accounts"].append( + { + "asset": str(asset), + "username": username, + } + ) + + # 远端上 比 收集账号少的 + # 标识 remote_present=False, 标记为待处理 + # 远端资产上不存在的,标识为待处理,需要管理员介入 + lost_users = ori_ga_users - remote_users + if lost_users: + queryset.filter(username__in=lost_users).update( + status=ConfirmOrIgnore.pending, remote_present=False + ) + self.summary["lost_accounts"] += len(lost_users) + for username in lost_users: + self.result["lost_accounts"].append( + { + "asset": str(asset), + "username": username, + } + ) + + # 收集的账号 比 账号列表多的, 有可能是账号中删掉了, 但这时候状态已经是 confirm 了 + # 标识状态为 待处理, 让管理员去确认 + ga_added_users = ori_ga_users - ori_users + if ga_added_users: + queryset.filter(username__in=ga_added_users).update(status=ConfirmOrIgnore.pending) + + # 收集的账号 比 账号列表少的 + # 这个好像不不用对比,原始情况就这样 + + # 远端账号 比 账号列表少的 + # 创建收集账号,标识 remote_present=False, 状态待处理 + + # 远端账号 比 账号列表多的 + # 正常情况, 不用处理,因为远端账号会创建到收集账号,收集账号再去对比 + + # 不过这个好像也处理一下 status,因为已存在,这是状态应该是确认 + ( + queryset.filter(username__in=ori_users) + .exclude(status=ConfirmOrIgnore.confirmed) + .update(status=ConfirmOrIgnore.confirmed) + ) + + # 远端存在的账号,标识为已存在 + ( + queryset.filter(username__in=remote_users, remote_present=False).update( + remote_present=True + ) + ) + + # 资产上没有的,标识为为存在 + ( + queryset.exclude(username__in=ori_users) + .filter(present=True) + .update(present=False) + ) + ( + queryset.filter(username__in=ori_users) + .filter(present=False) + .update(present=True) + ) + + @bulk_create_decorator(GatheredAccount) + def create_gathered_account(self, d): + ga = GatheredAccount() + for k, v in d.items(): + setattr(ga, k, v) + + return ga + + @bulk_update_decorator(GatheredAccount, update_fields=common_risk_items) + def update_gathered_account(self, ori_account, d): + diff = get_items_diff(ori_account, d) + if not diff: + return + for k in diff: + if k not in common_risk_items: + continue + v = d.get(k) + setattr(ori_account, k, v) + return ori_account + + def do_run(self, *args, **kwargs): + super().do_run(*args, **kwargs) + self.prefetch_origin_account_usernames() + risk_analyser = AnalyseAccountRisk(self.check_risk) + + for asset, accounts_data in self.asset_account_info.items(): + ori_users = self.ori_asset_usernames[str(asset.id)] + with tmp_to_org(asset.org_id): + for d in accounts_data: + username = d["username"] + ori_account = self.ori_gathered_accounts_mapper.get( + "{}_{}".format(asset.id, username) + ) + if not ori_account: + ga = self.create_gathered_account(d) + else: + ga = ori_account + self.update_gathered_account(ori_account, d) + ori_found = username in ori_users + risk_analyser.analyse_risk(asset, ga, d, ori_found) + + self.create_gathered_account.finish() + self.update_gathered_account.finish() + self.update_gather_accounts_status(asset) + if not self.is_sync_account: + continue + gathered_accounts = GatheredAccount.objects.filter(asset=asset) + GatheredAccount.sync_accounts(gathered_accounts, self.is_sync_account) + # 因为有 bulk create, bulk update, 所以这里需要 sleep 一下,等待数据同步 + time.sleep(0.5) + + def get_report_template(self): + return "accounts/gather_account_report.html" diff --git a/apps/accounts/automations/gather_accounts/filter.py b/apps/accounts/automations/gather_accounts/filter.py deleted file mode 100644 index 1cca4d980..000000000 --- a/apps/accounts/automations/gather_accounts/filter.py +++ /dev/null @@ -1,75 +0,0 @@ -import re - -from django.utils import timezone - -__all__ = ['GatherAccountsFilter'] - - -# TODO 后期会挪到playbook中 -class GatherAccountsFilter: - - def __init__(self, tp): - self.tp = tp - - @staticmethod - def mysql_filter(info): - result = {} - for _, user_dict in info.items(): - for username, _ in user_dict.items(): - if len(username.split('.')) == 1: - result[username] = {} - return result - - @staticmethod - def postgresql_filter(info): - result = {} - for username in info: - result[username] = {} - return result - - @staticmethod - def posix_filter(info): - username_pattern = re.compile(r'^(\S+)') - ip_pattern = re.compile(r'(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})') - login_time_pattern = re.compile(r'\w{3} \w{3}\s+\d{1,2} \d{2}:\d{2}:\d{2} \d{4}') - result = {} - for line in info: - usernames = username_pattern.findall(line) - username = ''.join(usernames) - if username: - result[username] = {} - else: - continue - ip_addrs = ip_pattern.findall(line) - ip_addr = ''.join(ip_addrs) - if ip_addr: - result[username].update({'address': ip_addr}) - login_times = login_time_pattern.findall(line) - if login_times: - datetime_str = login_times[0].split(' ', 1)[1] + " +0800" - date = timezone.datetime.strptime(datetime_str, '%b %d %H:%M:%S %Y %z') - result[username].update({'date': date}) - return result - - @staticmethod - def windows_filter(info): - info = info[4:-2] - result = {} - for i in info: - for username in i.split(): - result[username] = {} - return result - - def run(self, method_id_meta_mapper, info): - run_method_name = None - for k, v in method_id_meta_mapper.items(): - if self.tp not in v['type']: - continue - run_method_name = k.replace(f'{v["method"]}_', '') - - if not run_method_name: - return info - - if hasattr(self, f'{run_method_name}_filter'): - return getattr(self, f'{run_method_name}_filter')(info) - return info diff --git a/apps/accounts/automations/gather_accounts/host/posix/main.yml b/apps/accounts/automations/gather_accounts/host/posix/main.yml deleted file mode 100644 index d3cbe9c75..000000000 --- a/apps/accounts/automations/gather_accounts/host/posix/main.yml +++ /dev/null @@ -1,21 +0,0 @@ -- hosts: demo - gather_facts: no - tasks: - - name: Gather posix account - ansible.builtin.shell: - cmd: > - users=$(getent passwd | grep -v nologin | grep -v shutdown | awk -F":" '{ print $1 }');for i in $users; - do k=$(last -w -F $i -1 | head -1 | grep -v ^$ | awk '{ print $0 }') - if [ -n "$k" ]; then - echo $k - else - echo $i - fi;done - register: result - - - name: Define info by set_fact - ansible.builtin.set_fact: - info: "{{ result.stdout_lines }}" - - - debug: - var: info \ No newline at end of file diff --git a/apps/accounts/automations/gather_accounts/host/windows/main.yml b/apps/accounts/automations/gather_accounts/host/windows/main.yml deleted file mode 100644 index 6f36feb83..000000000 --- a/apps/accounts/automations/gather_accounts/host/windows/main.yml +++ /dev/null @@ -1,14 +0,0 @@ -- hosts: demo - gather_facts: no - tasks: - - name: Gather windows account - ansible.builtin.win_shell: net user - register: result - ignore_errors: true - - - name: Define info by set_fact - ansible.builtin.set_fact: - info: "{{ result.stdout_lines }}" - - - debug: - var: info \ No newline at end of file diff --git a/apps/accounts/automations/gather_accounts/manager.py b/apps/accounts/automations/gather_accounts/manager.py deleted file mode 100644 index e97d78cd0..000000000 --- a/apps/accounts/automations/gather_accounts/manager.py +++ /dev/null @@ -1,139 +0,0 @@ -from collections import defaultdict - -from accounts.const import AutomationTypes -from accounts.models import GatheredAccount -from assets.models import Asset -from common.utils import get_logger -from orgs.utils import tmp_to_org -from users.models import User -from .filter import GatherAccountsFilter -from ..base.manager import AccountBasePlaybookManager -from ...notifications import GatherAccountChangeMsg - -logger = get_logger(__name__) - - -class GatherAccountsManager(AccountBasePlaybookManager): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.host_asset_mapper = {} - self.asset_account_info = {} - - self.asset_username_mapper = defaultdict(set) - self.is_sync_account = self.execution.snapshot.get('is_sync_account') - - @classmethod - def method_type(cls): - return AutomationTypes.gather_accounts - - def host_callback(self, host, asset=None, **kwargs): - super().host_callback(host, asset=asset, **kwargs) - self.host_asset_mapper[host['name']] = asset - return host - - def filter_success_result(self, tp, result): - result = GatherAccountsFilter(tp).run(self.method_id_meta_mapper, result) - return result - - def generate_data(self, asset, result): - data = [] - for username, info in result.items(): - self.asset_username_mapper[str(asset.id)].add(username) - d = {'asset': asset, 'username': username, 'present': True} - if info.get('date'): - d['date_last_login'] = info['date'] - if info.get('address'): - d['address_last_login'] = info['address'][:32] - data.append(d) - return data - - def collect_asset_account_info(self, asset, result): - data = self.generate_data(asset, result) - self.asset_account_info[asset] = data - - @staticmethod - def get_nested_info(data, *keys): - for key in keys: - data = data.get(key, {}) - if not data: - break - return data - - def on_host_success(self, host, result): - info = self.get_nested_info(result, 'debug', 'res', 'info') - asset = self.host_asset_mapper.get(host) - if asset and info: - result = self.filter_success_result(asset.type, info) - self.collect_asset_account_info(asset, result) - else: - print(f'\033[31m Not found {host} info \033[0m\n') - - def update_or_create_accounts(self): - for asset, data in self.asset_account_info.items(): - with tmp_to_org(asset.org_id): - gathered_accounts = [] - GatheredAccount.objects.filter(asset=asset, present=True).update(present=False) - for d in data: - username = d['username'] - gathered_account, __ = GatheredAccount.objects.update_or_create( - defaults=d, asset=asset, username=username, - ) - gathered_accounts.append(gathered_account) - if not self.is_sync_account: - continue - GatheredAccount.sync_accounts(gathered_accounts) - - def run(self, *args, **kwargs): - super().run(*args, **kwargs) - users, change_info = self.generate_send_users_and_change_info() - self.update_or_create_accounts() - self.send_email_if_need(users, change_info) - - def generate_send_users_and_change_info(self): - recipients = self.execution.recipients - if not self.asset_username_mapper or not recipients: - return None, None - - users = User.objects.filter(id__in=recipients) - if not users.exists(): - return users, None - - asset_ids = self.asset_username_mapper.keys() - - assets = Asset.objects.filter(id__in=asset_ids).prefetch_related('accounts') - gather_accounts = GatheredAccount.objects.filter(asset_id__in=asset_ids, present=True) - - asset_id_map = {str(asset.id): asset for asset in assets} - asset_id_username = list(assets.values_list('id', 'accounts__username')) - asset_id_username.extend(list(gather_accounts.values_list('asset_id', 'username'))) - - system_asset_username_mapper = defaultdict(set) - for asset_id, username in asset_id_username: - system_asset_username_mapper[str(asset_id)].add(username) - - change_info = defaultdict(dict) - for asset_id, usernames in self.asset_username_mapper.items(): - system_usernames = system_asset_username_mapper.get(asset_id) - if not system_usernames: - continue - - add_usernames = usernames - system_usernames - remove_usernames = system_usernames - usernames - - if not add_usernames and not remove_usernames: - continue - - change_info[str(asset_id_map[asset_id])] = { - 'add_usernames': add_usernames, - 'remove_usernames': remove_usernames - } - - return users, dict(change_info) - - @staticmethod - def send_email_if_need(users, change_info): - if not users or not change_info: - return - - for user in users: - GatherAccountChangeMsg(user, change_info).publish_async() diff --git a/apps/accounts/automations/push_account/custom/ssh/main.yml b/apps/accounts/automations/push_account/custom/ssh/main.yml new file mode 100644 index 000000000..966454bfc --- /dev/null +++ b/apps/accounts/automations/push_account/custom/ssh/main.yml @@ -0,0 +1,62 @@ +- hosts: custom + gather_facts: no + vars: + ansible_connection: local + ansible_become: false + + tasks: + - name: Test privileged account (paramiko) + ssh_ping: + login_host: "{{ jms_asset.address }}" + login_port: "{{ jms_asset.port }}" + login_user: "{{ jms_account.username }}" + login_password: "{{ jms_account.secret }}" + login_secret_type: "{{ jms_account.secret_type }}" + login_private_key_path: "{{ jms_account.private_key_path }}" + become: "{{ jms_custom_become | default(False) }}" + become_method: "{{ jms_custom_become_method | default('su') }}" + become_user: "{{ jms_custom_become_user | default('') }}" + become_password: "{{ jms_custom_become_password | default('') }}" + become_private_key_path: "{{ jms_custom_become_private_key_path | default(None) }}" + old_ssh_version: "{{ jms_asset.old_ssh_version | default(False) }}" + gateway_args: "{{ jms_asset.ansible_ssh_common_args | default(None) }}" + register: ping_info + delegate_to: localhost + + - name: Change asset password (paramiko) + custom_command: + login_user: "{{ jms_account.username }}" + login_password: "{{ jms_account.secret }}" + login_host: "{{ jms_asset.address }}" + login_port: "{{ jms_asset.port }}" + login_secret_type: "{{ jms_account.secret_type }}" + login_private_key_path: "{{ jms_account.private_key_path }}" + become: "{{ jms_custom_become | default(False) }}" + become_method: "{{ jms_custom_become_method | default('su') }}" + become_user: "{{ jms_custom_become_user | default('') }}" + become_password: "{{ jms_custom_become_password | default('') }}" + become_private_key_path: "{{ jms_custom_become_private_key_path | default(None) }}" + name: "{{ account.username }}" + password: "{{ account.secret }}" + commands: "{{ params.commands }}" + first_conn_delay_time: "{{ first_conn_delay_time | default(0.5) }}" + ignore_errors: true + when: ping_info is succeeded and check_conn_after_change + register: change_info + delegate_to: localhost + + - name: Verify password (paramiko) + ssh_ping: + login_user: "{{ account.username }}" + login_password: "{{ account.secret }}" + login_host: "{{ jms_asset.address }}" + login_port: "{{ jms_asset.port }}" + become: "{{ account.become.ansible_become | default(False) }}" + become_method: su + become_user: "{{ account.become.ansible_user | default('') }}" + become_password: "{{ account.become.ansible_password | default('') }}" + become_private_key_path: "{{ account.become.ansible_ssh_private_key_file | default(None) }}" + old_ssh_version: "{{ jms_asset.old_ssh_version | default(False) }}" + gateway_args: "{{ jms_asset.ansible_ssh_common_args | default(None) }}" + delegate_to: localhost + when: check_conn_after_change \ No newline at end of file diff --git a/apps/accounts/automations/push_account/custom/ssh/manifest.yml b/apps/accounts/automations/push_account/custom/ssh/manifest.yml new file mode 100644 index 000000000..2330224ca --- /dev/null +++ b/apps/accounts/automations/push_account/custom/ssh/manifest.yml @@ -0,0 +1,32 @@ +id: push_account_by_ssh +name: "{{ 'SSH account push' | trans }}" +category: + - device + - host +type: + - all +method: push_account +protocol: ssh +priority: 50 +params: + - name: commands + type: list + label: "{{ 'Params commands label' | trans }}" + default: [ '' ] + help_text: "{{ 'Params commands help text' | trans }}" + +i18n: + SSH account push: + zh: '使用 SSH 命令行自定义推送' + ja: 'SSHコマンドラインを使用してプッシュをカスタマイズする' + en: 'Custom push using SSH command line' + + Params commands help text: + zh: '自定义命令中如需包含账号的 账号、密码、SSH 连接的用户密码 字段,
请使用 {username}、{password}、{login_password}格式,执行任务时会进行替换 。
比如针对 Cisco 主机进行改密,一般需要配置五条命令:
1. enable
2. {login_password}
3. configure terminal
4. username {username} privilege 0 password {password}
5. end' + ja: 'カスタム コマンドに SSH 接続用のアカウント番号、パスワード、ユーザー パスワード フィールドを含める必要がある場合は、
{ユーザー名}、{パスワード}、{login_password& を使用してください。 # 125; 形式。タスクの実行時に置き換えられます。
たとえば、Cisco ホストのパスワードを変更するには、通常、次の 5 つのコマンドを設定する必要があります:
1.enable
2.{login_password}
3 .ターミナルの設定
4. ユーザー名 {ユーザー名} 権限 0 パスワード {パスワード}
5. 終了' + en: 'If the custom command needs to include the account number, password, and user password field for SSH connection,
Please use {username}, {password}, {login_password&# 125; format, which will be replaced when executing the task.
For example, to change the password of a Cisco host, you generally need to configure five commands:
1. enable
2. {login_password}
3. configure terminal
4. username {username} privilege 0 password {password}
5. end' + + Params commands label: + zh: '自定义命令' + ja: 'カスタムコマンド' + en: 'Custom command' diff --git a/apps/accounts/automations/push_account/database/mongodb/main.yml b/apps/accounts/automations/push_account/database/mongodb/main.yml index 724c59a94..c6fd84297 100644 --- a/apps/accounts/automations/push_account/database/mongodb/main.yml +++ b/apps/accounts/automations/push_account/database/mongodb/main.yml @@ -1,7 +1,7 @@ - hosts: mongodb gather_facts: no vars: - ansible_python_interpreter: /opt/py3/bin/python + ansible_python_interpreter: "{{ local_python_interpreter }}" tasks: - name: Test MongoDB connection @@ -53,3 +53,4 @@ ssl_certfile: "{{ jms_asset.secret_info.client_key | default('') }}" connection_options: - tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert}}" + when: check_conn_after_change diff --git a/apps/accounts/automations/push_account/database/mysql/main.yml b/apps/accounts/automations/push_account/database/mysql/main.yml index 0d8452a4a..54c7fcf41 100644 --- a/apps/accounts/automations/push_account/database/mysql/main.yml +++ b/apps/accounts/automations/push_account/database/mysql/main.yml @@ -1,7 +1,7 @@ - hosts: mysql gather_facts: no vars: - ansible_python_interpreter: /opt/py3/bin/python + ansible_python_interpreter: "{{ local_python_interpreter }}" db_name: "{{ jms_asset.spec_info.db_name }}" check_ssl: "{{ jms_asset.spec_info.use_ssl and not jms_asset.spec_info.allow_invalid_cert }}" ca_cert: "{{ jms_asset.secret_info.ca_cert | default('') }}" @@ -54,3 +54,4 @@ client_cert: "{{ ssl_cert if check_ssl and ssl_cert | length > 0 else omit }}" client_key: "{{ ssl_key if check_ssl and ssl_key | length > 0 else omit }}" filter: version + when: check_conn_after_change diff --git a/apps/accounts/automations/push_account/database/oracle/main.yml b/apps/accounts/automations/push_account/database/oracle/main.yml index 5a94f3184..6b91b7a2b 100644 --- a/apps/accounts/automations/push_account/database/oracle/main.yml +++ b/apps/accounts/automations/push_account/database/oracle/main.yml @@ -1,7 +1,7 @@ - hosts: oracle gather_facts: no vars: - ansible_python_interpreter: /opt/py3/bin/python + ansible_python_interpreter: "{{ local_python_interpreter }}" tasks: - name: Test Oracle connection @@ -40,3 +40,4 @@ login_port: "{{ jms_asset.port }}" login_database: "{{ jms_asset.spec_info.db_name }}" mode: "{{ account.mode }}" + when: check_conn_after_change diff --git a/apps/accounts/automations/push_account/database/postgresql/main.yml b/apps/accounts/automations/push_account/database/postgresql/main.yml index 1a74d0cb0..3695e297a 100644 --- a/apps/accounts/automations/push_account/database/postgresql/main.yml +++ b/apps/accounts/automations/push_account/database/postgresql/main.yml @@ -1,7 +1,7 @@ - hosts: postgre gather_facts: no vars: - ansible_python_interpreter: /opt/py3/bin/python + ansible_python_interpreter: "{{ local_python_interpreter }}" check_ssl: "{{ jms_asset.spec_info.use_ssl }}" ca_cert: "{{ jms_asset.secret_info.ca_cert | default('') }}" ssl_cert: "{{ jms_asset.secret_info.client_cert | default('') }}" @@ -59,5 +59,6 @@ when: - result is succeeded - change_info is succeeded + - check_conn_after_change register: result failed_when: not result.is_available diff --git a/apps/accounts/automations/push_account/database/sqlserver/main.yml b/apps/accounts/automations/push_account/database/sqlserver/main.yml index ee6c4aa5f..bb66c559a 100644 --- a/apps/accounts/automations/push_account/database/sqlserver/main.yml +++ b/apps/accounts/automations/push_account/database/sqlserver/main.yml @@ -1,7 +1,7 @@ - hosts: sqlserver gather_facts: no vars: - ansible_python_interpreter: /opt/py3/bin/python + ansible_python_interpreter: "{{ local_python_interpreter }}" tasks: - name: Test SQLServer connection @@ -66,3 +66,4 @@ name: '{{ jms_asset.spec_info.db_name }}' script: | SELECT @@version + when: check_conn_after_change diff --git a/apps/accounts/automations/push_account/host/aix/main.yml b/apps/accounts/automations/push_account/host/aix/main.yml index 8e451fb83..92c8a5658 100644 --- a/apps/accounts/automations/push_account/host/aix/main.yml +++ b/apps/accounts/automations/push_account/host/aix/main.yml @@ -9,7 +9,8 @@ database: passwd key: "{{ account.username }}" register: user_info - ignore_errors: yes # 忽略错误,如果用户不存在时不会导致playbook失败 + failed_when: false + changed_when: false - name: "Add {{ account.username }} user" ansible.builtin.user: @@ -18,10 +19,10 @@ shell: "{{ params.shell if params.shell | length > 0 else omit }}" home: "{{ params.home if params.home | length > 0 else '/home/' + account.username }}" groups: "{{ params.groups if params.groups | length > 0 else omit }}" - append: yes + append: "{{ true if params.groups | length > 0 else false }}" expires: -1 state: present - when: user_info.failed + when: user_info.msg is defined - name: "Set {{ account.username }} sudo setting" ansible.builtin.lineinfile: @@ -31,7 +32,7 @@ line: "{{ account.username + ' ALL=(ALL) NOPASSWD: ' + params.sudo }}" validate: visudo -cf %s when: - - user_info.failed or params.modify_sudo + - user_info.msg is defined or params.modify_sudo - params.sudo - name: "Change {{ account.username }} password" @@ -100,7 +101,7 @@ become_password: "{{ account.become.ansible_password | default('') }}" become_private_key_path: "{{ account.become.ansible_ssh_private_key_file | default(None) }}" old_ssh_version: "{{ jms_asset.old_ssh_version | default(False) }}" - when: account.secret_type == "password" + when: account.secret_type == "password" and check_conn_after_change delegate_to: localhost - name: "Verify {{ account.username }} SSH KEY (paramiko)" @@ -111,6 +112,6 @@ login_private_key_path: "{{ account.private_key_path }}" gateway_args: "{{ jms_asset.ansible_ssh_common_args | default(None) }}" old_ssh_version: "{{ jms_asset.old_ssh_version | default(False) }}" - when: account.secret_type == "ssh_key" + when: account.secret_type == "ssh_key" and check_conn_after_change delegate_to: localhost diff --git a/apps/accounts/automations/push_account/host/posix/main.yml b/apps/accounts/automations/push_account/host/posix/main.yml index 537256a3d..b47e6745e 100644 --- a/apps/accounts/automations/push_account/host/posix/main.yml +++ b/apps/accounts/automations/push_account/host/posix/main.yml @@ -9,7 +9,8 @@ database: passwd key: "{{ account.username }}" register: user_info - ignore_errors: yes # 忽略错误,如果用户不存在时不会导致playbook失败 + failed_when: false + changed_when: false - name: "Add {{ account.username }} user" ansible.builtin.user: @@ -18,10 +19,10 @@ shell: "{{ params.shell if params.shell | length > 0 else omit }}" home: "{{ params.home if params.home | length > 0 else '/home/' + account.username }}" groups: "{{ params.groups if params.groups | length > 0 else omit }}" - append: yes + append: "{{ true if params.groups | length > 0 else false }}" expires: -1 state: present - when: user_info.failed + when: user_info.msg is defined - name: "Set {{ account.username }} sudo setting" ansible.builtin.lineinfile: @@ -31,7 +32,7 @@ line: "{{ account.username + ' ALL=(ALL) NOPASSWD: ' + params.sudo }}" validate: visudo -cf %s when: - - user_info.failed or params.modify_sudo + - user_info.msg is defined or params.modify_sudo - params.sudo - name: "Change {{ account.username }} password" @@ -100,7 +101,7 @@ become_password: "{{ account.become.ansible_password | default('') }}" become_private_key_path: "{{ account.become.ansible_ssh_private_key_file | default(None) }}" old_ssh_version: "{{ jms_asset.old_ssh_version | default(False) }}" - when: account.secret_type == "password" + when: account.secret_type == "password" and check_conn_after_change delegate_to: localhost - name: "Verify {{ account.username }} SSH KEY (paramiko)" @@ -111,6 +112,6 @@ login_private_key_path: "{{ account.private_key_path }}" gateway_args: "{{ jms_asset.ansible_ssh_common_args | default(None) }}" old_ssh_version: "{{ jms_asset.old_ssh_version | default(False) }}" - when: account.secret_type == "ssh_key" + when: account.secret_type == "ssh_key" and check_conn_after_change delegate_to: localhost diff --git a/apps/accounts/automations/push_account/host/windows/main.yml b/apps/accounts/automations/push_account/host/windows/main.yml index 17f68b660..0cb67a3a7 100644 --- a/apps/accounts/automations/push_account/host/windows/main.yml +++ b/apps/accounts/automations/push_account/host/windows/main.yml @@ -4,10 +4,6 @@ - name: Test privileged account ansible.windows.win_ping: -# - name: Print variables -# debug: -# msg: "Username: {{ account.username }}, Password: {{ account.secret }}" - - name: Push user password ansible.windows.win_user: fullname: "{{ account.username}}" @@ -28,4 +24,4 @@ vars: ansible_user: "{{ account.username }}" ansible_password: "{{ account.secret }}" - when: account.secret_type == "password" + when: account.secret_type == "password" and check_conn_after_change diff --git a/apps/accounts/automations/push_account/host/windows_rdp_verify/main.yml b/apps/accounts/automations/push_account/host/windows_rdp_verify/main.yml index 0c650bfb3..a3aeb3ba6 100644 --- a/apps/accounts/automations/push_account/host/windows_rdp_verify/main.yml +++ b/apps/accounts/automations/push_account/host/windows_rdp_verify/main.yml @@ -4,10 +4,6 @@ - name: Test privileged account ansible.windows.win_ping: -# - name: Print variables -# debug: -# msg: "Username: {{ account.username }}, Password: {{ account.secret }}" - - name: Push user password ansible.windows.win_user: fullname: "{{ account.username}}" @@ -31,5 +27,5 @@ login_password: "{{ account.secret }}" login_secret_type: "{{ account.secret_type }}" gateway_args: "{{ jms_gateway | default({}) }}" - when: account.secret_type == "password" + when: account.secret_type == "password" and check_conn_after_change delegate_to: localhost diff --git a/apps/accounts/automations/push_account/manager.py b/apps/accounts/automations/push_account/manager.py index e5a6db375..448f6afb7 100644 --- a/apps/accounts/automations/push_account/manager.py +++ b/apps/accounts/automations/push_account/manager.py @@ -1,12 +1,16 @@ +from django.utils.translation import gettext_lazy as _ + from accounts.const import AutomationTypes +from common.decorators import bulk_create_decorator from common.utils import get_logger -from ..base.manager import AccountBasePlaybookManager -from ..change_secret.manager import ChangeSecretManager +from common.utils.timezone import local_now_filename +from ..base.manager import BaseChangeSecretPushManager +from ...models import PushSecretRecord logger = get_logger(__name__) -class PushAccountManager(ChangeSecretManager, AccountBasePlaybookManager): +class PushAccountManager(BaseChangeSecretPushManager): @staticmethod def require_update_version(account, recorder): @@ -17,63 +21,43 @@ class PushAccountManager(ChangeSecretManager, AccountBasePlaybookManager): def method_type(cls): return AutomationTypes.push_account - # @classmethod - # def trigger_by_asset_create(cls, asset): - # automations = PushAccountAutomation.objects.filter( - # triggers__contains=TriggerChoice.on_asset_create - # ) - # account_automation_map = {auto.username: auto for auto in automations} - # - # util = AssetPermissionUtil() - # permissions = util.get_permissions_for_assets([asset], with_node=True) - # account_permission_map = defaultdict(list) - # for permission in permissions: - # for account in permission.accounts: - # account_permission_map[account].append(permission) - # - # username_automation_map = {} - # for username, automation in account_automation_map.items(): - # if username != '@USER': - # username_automation_map[username] = automation - # continue - # - # asset_permissions = account_permission_map.get(username) - # if not asset_permissions: - # continue - # asset_permissions = util.get_permissions([p.id for p in asset_permissions]) - # usernames = asset_permissions.values_list('users__username', flat=True).distinct() - # for _username in usernames: - # username_automation_map[_username] = automation - # - # asset_usernames_exists = asset.accounts.values_list('username', flat=True) - # accounts_to_create = [] - # accounts_to_push = [] - # for username, automation in username_automation_map.items(): - # if username in asset_usernames_exists: - # continue - # - # if automation.secret_strategy != SecretStrategy.custom: - # secret_generator = SecretGenerator( - # automation.secret_strategy, automation.secret_type, - # automation.password_rules - # ) - # secret = secret_generator.get_secret() - # else: - # secret = automation.secret - # - # account = Account( - # username=username, secret=secret, - # asset=asset, secret_type=automation.secret_type, - # comment='Create by account creation {}'.format(automation.name), - # ) - # accounts_to_create.append(account) - # if automation.action == 'create_and_push': - # accounts_to_push.append(account) - # else: - # accounts_to_create.append(account) - # - # logger.debug(f'Create account {account} for asset {asset}') + def get_secret(self, account): + return account.secret - # @classmethod - # def trigger_by_permission_accounts_change(cls): - # pass + def gen_account_inventory(self, account, asset, h, path_dir): + self.get_or_create_record(asset, account, h['name']) + secret = self.get_secret(account) + secret_type = account.secret_type + new_secret, private_key_path = self.handle_ssh_secret(secret_type, secret, path_dir) + h = self.gen_inventory(h, account, new_secret, private_key_path, asset) + return h + + def get_or_create_record(self, asset, account, name): + asset_account_id = f'{asset.id}-{account.id}' + + if asset_account_id in self.record_map: + record_id = self.record_map[asset_account_id] + recorder = PushSecretRecord.objects.filter(id=record_id).first() + else: + recorder = self.create_record(asset, account) + + self.name_recorder_mapper[name] = recorder + return recorder + + @bulk_create_decorator(PushSecretRecord) + def create_record(self, asset, account): + recorder = PushSecretRecord( + asset=asset, account=account, execution=self.execution, + comment=f'{account.username}@{asset.address}' + ) + return recorder + + def print_summary(self): + print('\n\n' + '-' * 80) + plan_execution_end = _('Plan execution end') + print('{} {}\n'.format(plan_execution_end, local_now_filename())) + time_cost = _('Duration') + print('{}: {}s'.format(time_cost, self.duration)) + + def get_report_template(self): + return "accounts/push_account_report.html" diff --git a/apps/accounts/automations/remove_account/database/mongodb/main.yml b/apps/accounts/automations/remove_account/database/mongodb/main.yml index 3ec800981..c4ef222e4 100644 --- a/apps/accounts/automations/remove_account/database/mongodb/main.yml +++ b/apps/accounts/automations/remove_account/database/mongodb/main.yml @@ -1,7 +1,7 @@ - hosts: mongodb gather_facts: no vars: - ansible_python_interpreter: /opt/py3/bin/python + ansible_python_interpreter: "{{ local_python_interpreter }}" tasks: - name: "Remove account" diff --git a/apps/accounts/automations/remove_account/database/mysql/main.yml b/apps/accounts/automations/remove_account/database/mysql/main.yml index f877dfe18..de82059ca 100644 --- a/apps/accounts/automations/remove_account/database/mysql/main.yml +++ b/apps/accounts/automations/remove_account/database/mysql/main.yml @@ -1,7 +1,7 @@ - hosts: mysql gather_facts: no vars: - ansible_python_interpreter: /opt/py3/bin/python + ansible_python_interpreter: "{{ local_python_interpreter }}" check_ssl: "{{ jms_asset.spec_info.use_ssl and not jms_asset.spec_info.allow_invalid_cert }}" ca_cert: "{{ jms_asset.secret_info.ca_cert | default('') }}" ssl_cert: "{{ jms_asset.secret_info.client_cert | default('') }}" diff --git a/apps/accounts/automations/remove_account/database/oracle/main.yml b/apps/accounts/automations/remove_account/database/oracle/main.yml index ffd846d47..2e35dfd40 100644 --- a/apps/accounts/automations/remove_account/database/oracle/main.yml +++ b/apps/accounts/automations/remove_account/database/oracle/main.yml @@ -1,7 +1,7 @@ - hosts: oracle gather_facts: no vars: - ansible_python_interpreter: /opt/py3/bin/python + ansible_python_interpreter: "{{ local_python_interpreter }}" tasks: - name: "Remove account" diff --git a/apps/accounts/automations/remove_account/database/postgresql/main.yml b/apps/accounts/automations/remove_account/database/postgresql/main.yml index 3aad331f2..c63de34fe 100644 --- a/apps/accounts/automations/remove_account/database/postgresql/main.yml +++ b/apps/accounts/automations/remove_account/database/postgresql/main.yml @@ -1,7 +1,7 @@ - hosts: postgresql gather_facts: no vars: - ansible_python_interpreter: /opt/py3/bin/python + ansible_python_interpreter: "{{ local_python_interpreter }}" check_ssl: "{{ jms_asset.spec_info.use_ssl }}" ca_cert: "{{ jms_asset.secret_info.ca_cert | default('') }}" ssl_cert: "{{ jms_asset.secret_info.client_cert | default('') }}" diff --git a/apps/accounts/automations/remove_account/database/sqlserver/main.yml b/apps/accounts/automations/remove_account/database/sqlserver/main.yml index 597e12906..001879395 100644 --- a/apps/accounts/automations/remove_account/database/sqlserver/main.yml +++ b/apps/accounts/automations/remove_account/database/sqlserver/main.yml @@ -1,7 +1,7 @@ - hosts: sqlserver gather_facts: no vars: - ansible_python_interpreter: /opt/py3/bin/python + ansible_python_interpreter: "{{ local_python_interpreter }}" tasks: - name: "Remove account" diff --git a/apps/accounts/automations/remove_account/manager.py b/apps/accounts/automations/remove_account/manager.py index 38a22538b..2d194164f 100644 --- a/apps/accounts/automations/remove_account/manager.py +++ b/apps/accounts/automations/remove_account/manager.py @@ -1,10 +1,12 @@ import os +from collections import defaultdict from copy import deepcopy from django.db.models import QuerySet from accounts.const import AutomationTypes -from accounts.models import Account +from accounts.models import Account, GatheredAccount, AccountRisk +from common.const import ConfirmOrIgnore from common.utils import get_logger from ..base.manager import AccountBasePlaybookManager @@ -12,59 +14,82 @@ logger = get_logger(__name__) class RemoveAccountManager(AccountBasePlaybookManager): + super_accounts = ["root", "administrator"] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.host_account_mapper = {} + self.host_account_mapper = dict() + self.host_accounts = defaultdict(list) + snapshot_account = self.execution.snapshot.get("accounts", []) + self.snapshot_asset_account_map = defaultdict(list) + for account in snapshot_account: + self.snapshot_asset_account_map[str(account["asset"])].append(account) + + # 给 handler 使用 + self.delete = self.execution.snapshot.get("delete", "both") + self.confirm_risk = self.execution.snapshot.get("risk", "") def prepare_runtime_dir(self): path = super().prepare_runtime_dir() - ansible_config_path = os.path.join(path, 'ansible.cfg') + ansible_config_path = os.path.join(path, "ansible.cfg") - with open(ansible_config_path, 'w') as f: - f.write('[ssh_connection]\n') - f.write('ssh_args = -o ControlMaster=no -o ControlPersist=no\n') + with open(ansible_config_path, "w") as f: + f.write("[ssh_connection]\n") + f.write("ssh_args = -o ControlMaster=no -o ControlPersist=no\n") return path @classmethod def method_type(cls): return AutomationTypes.remove_account - def get_gather_accounts(self, privilege_account, gather_accounts: QuerySet): - gather_account_ids = self.execution.snapshot['gather_accounts'] - gather_accounts = gather_accounts.filter(id__in=gather_account_ids) - gather_accounts = gather_accounts.exclude( - username__in=[privilege_account.username, 'root', 'Administrator'] - ) - return gather_accounts - - def host_callback(self, host, asset=None, account=None, automation=None, path_dir=None, **kwargs): - if host.get('error'): + def host_callback( + self, host, asset=None, account=None, automation=None, path_dir=None, **kwargs + ): + if host.get("error"): return host - gather_accounts = asset.gatheredaccount_set.all() - gather_accounts = self.get_gather_accounts(account, gather_accounts) - inventory_hosts = [] + accounts_to_remove = self.snapshot_asset_account_map.get(str(asset.id), []) - for gather_account in gather_accounts: + for account in accounts_to_remove: + username = account.get("username") + if not username or username.lower() in self.super_accounts: + print("Super account can not be remove: ", username) + continue h = deepcopy(host) - h['name'] += '(' + gather_account.username + ')' - self.host_account_mapper[h['name']] = (asset, gather_account) - h['account'] = {'username': gather_account.username} + h["name"] += "(" + username + ")" + self.host_account_mapper[h["name"]] = account + h["account"] = {"username": username} inventory_hosts.append(h) return inventory_hosts def on_host_success(self, host, result): - tuple_asset_gather_account = self.host_account_mapper.get(host) - if not tuple_asset_gather_account: + super().on_host_success(host, result) + account = self.host_account_mapper.get(host) + + if not account: return - asset, gather_account = tuple_asset_gather_account + try: - Account.objects.filter( - asset_id=asset.id, - username=gather_account.username + if self.delete == "both": + Account.objects.filter( + asset_id=account["asset"], + username=account["username"] + ).delete() + + if self.confirm_risk: + AccountRisk.objects.filter( + asset_id=account["asset"], + username=account["username"], + risk__in=[self.confirm_risk], + ).update(status=ConfirmOrIgnore.confirmed) + + GatheredAccount.objects.filter( + asset_id=account["asset"], + username=account["username"] ).delete() - gather_account.delete() + except Exception as e: - print(f'\033[31m Delete account {gather_account.username} failed: {e} \033[0m\n') + logger.error( + f"Failed to delete account {account['username']} on asset {account['asset']}: {e}" + ) diff --git a/apps/accounts/automations/verify_account/custom/ssh/main.yml b/apps/accounts/automations/verify_account/custom/ssh/main.yml index 31178666f..831c1c783 100644 --- a/apps/accounts/automations/verify_account/custom/ssh/main.yml +++ b/apps/accounts/automations/verify_account/custom/ssh/main.yml @@ -21,3 +21,4 @@ become_private_key_path: "{{ account.become.ansible_ssh_private_key_file | default(None) }}" old_ssh_version: "{{ jms_asset.old_ssh_version | default(False) }}" gateway_args: "{{ jms_asset.ansible_ssh_common_args | default(None) }}" + recv_timeout: "{{ params.recv_timeout | default(30) }}" diff --git a/apps/accounts/automations/verify_account/database/mongodb/main.yml b/apps/accounts/automations/verify_account/database/mongodb/main.yml index 13ecccb61..c50cbb1e5 100644 --- a/apps/accounts/automations/verify_account/database/mongodb/main.yml +++ b/apps/accounts/automations/verify_account/database/mongodb/main.yml @@ -1,7 +1,7 @@ - hosts: mongodb gather_facts: no vars: - ansible_python_interpreter: /opt/py3/bin/python + ansible_python_interpreter: "{{ local_python_interpreter }}" tasks: - name: Verify account diff --git a/apps/accounts/automations/verify_account/database/mysql/main.yml b/apps/accounts/automations/verify_account/database/mysql/main.yml index 2c4ae5c0b..b1b200f78 100644 --- a/apps/accounts/automations/verify_account/database/mysql/main.yml +++ b/apps/accounts/automations/verify_account/database/mysql/main.yml @@ -1,7 +1,7 @@ - hosts: mysql gather_facts: no vars: - ansible_python_interpreter: /opt/py3/bin/python + ansible_python_interpreter: "{{ local_python_interpreter }}" check_ssl: "{{ jms_asset.spec_info.use_ssl and not jms_asset.spec_info.allow_invalid_cert }}" ca_cert: "{{ jms_asset.secret_info.ca_cert | default('') }}" ssl_cert: "{{ jms_asset.secret_info.client_cert | default('') }}" diff --git a/apps/accounts/automations/verify_account/database/oracle/main.yml b/apps/accounts/automations/verify_account/database/oracle/main.yml index aa61fd349..3f5653ae3 100644 --- a/apps/accounts/automations/verify_account/database/oracle/main.yml +++ b/apps/accounts/automations/verify_account/database/oracle/main.yml @@ -1,7 +1,7 @@ - hosts: oracle gather_facts: no vars: - ansible_python_interpreter: /opt/py3/bin/python + ansible_python_interpreter: "{{ local_python_interpreter }}" tasks: - name: Verify account diff --git a/apps/accounts/automations/verify_account/database/postgresql/main.yml b/apps/accounts/automations/verify_account/database/postgresql/main.yml index 9667d335b..2937eb239 100644 --- a/apps/accounts/automations/verify_account/database/postgresql/main.yml +++ b/apps/accounts/automations/verify_account/database/postgresql/main.yml @@ -1,7 +1,7 @@ - hosts: postgresql gather_facts: no vars: - ansible_python_interpreter: /opt/py3/bin/python + ansible_python_interpreter: "{{ local_python_interpreter }}" check_ssl: "{{ jms_asset.spec_info.use_ssl }}" ca_cert: "{{ jms_asset.secret_info.ca_cert | default('') }}" ssl_cert: "{{ jms_asset.secret_info.client_cert | default('') }}" diff --git a/apps/accounts/automations/verify_account/database/sqlserver/main.yml b/apps/accounts/automations/verify_account/database/sqlserver/main.yml index df9830132..0a0509656 100644 --- a/apps/accounts/automations/verify_account/database/sqlserver/main.yml +++ b/apps/accounts/automations/verify_account/database/sqlserver/main.yml @@ -1,7 +1,7 @@ - hosts: sqlserver gather_facts: no vars: - ansible_python_interpreter: /opt/py3/bin/python + ansible_python_interpreter: "{{ local_python_interpreter }}" tasks: - name: Verify account diff --git a/apps/accounts/const/account.py b/apps/accounts/const/account.py index d18036bcc..6dd090fc3 100644 --- a/apps/accounts/const/account.py +++ b/apps/accounts/const/account.py @@ -24,7 +24,7 @@ class AliasAccount(TextChoices): class Source(TextChoices): LOCAL = 'local', _('Local') - COLLECTED = 'collected', _('Collected') + DISCOVERY = 'collected', _('Discovery') TEMPLATE = 'template', _('Template') diff --git a/apps/accounts/const/automation.py b/apps/accounts/const/automation.py index d8388b6bc..450826187 100644 --- a/apps/accounts/const/automation.py +++ b/apps/accounts/const/automation.py @@ -17,6 +17,7 @@ __all__ = [ 'AutomationTypes', 'SecretStrategy', 'SSHKeyStrategy', 'Connectivity', 'DEFAULT_PASSWORD_LENGTH', 'DEFAULT_PASSWORD_RULES', 'TriggerChoice', 'PushAccountActionChoice', 'AccountBackupType', 'ChangeSecretRecordStatusChoice', + 'GatherAccountDetailField' ] @@ -27,18 +28,23 @@ class AutomationTypes(models.TextChoices): remove_account = 'remove_account', _('Remove account') gather_accounts = 'gather_accounts', _('Gather accounts') verify_gateway_account = 'verify_gateway_account', _('Verify gateway account') + check_account = 'check_account', _('Check account') + backup_account = 'backup_account', _('Backup account') @classmethod def get_type_model(cls, tp): from accounts.models import ( PushAccountAutomation, ChangeSecretAutomation, VerifyAccountAutomation, GatherAccountsAutomation, + CheckAccountAutomation, BackupAccountAutomation ) type_model_dict = { cls.push_account: PushAccountAutomation, cls.change_secret: ChangeSecretAutomation, cls.verify_account: VerifyAccountAutomation, cls.gather_accounts: GatherAccountsAutomation, + cls.check_account: CheckAccountAutomation, + cls.backup_account: BackupAccountAutomation, } return type_model_dict.get(tp) @@ -49,9 +55,9 @@ class SecretStrategy(models.TextChoices): class SSHKeyStrategy(models.TextChoices): + # add = 'add', _('Append SSH KEY') set_jms = 'set_jms', _('Replace (Replace only keys pushed by JumpServer) ') set = 'set', _('Empty and append SSH KEY') - add = 'add', _('Append SSH KEY') class TriggerChoice(models.TextChoices, TreeChoices): @@ -109,3 +115,20 @@ class ChangeSecretRecordStatusChoice(models.TextChoices): failed = 'failed', _('Failed') success = 'success', _('Success') pending = 'pending', _('Pending') + + +class GatherAccountDetailField(models.TextChoices): + can_login = 'can_login', _('Can login') + superuser = 'superuser', _('Superuser') + create_date = 'create_date', _('Create date') + is_disabled = 'is_disabled', _('Is disabled') + default_database_name = 'default_database_name', _('Default database name') + uid = 'uid', _('UID') + account_status = 'account_status', _('Account status') + default_tablespace = 'default_tablespace', _('Default tablespace') + roles = 'roles', _('Roles') + privileges = 'privileges', _('Privileges') + groups = 'groups', _('Groups') + sudoers = 'sudoers', 'sudoers' + authorized_keys = 'authorized_keys', _('Authorized keys') + db = 'db', _('DB') diff --git a/apps/accounts/demos/curl/README.en.md b/apps/accounts/demos/curl/README.en.md new file mode 100644 index 000000000..3cc315d97 --- /dev/null +++ b/apps/accounts/demos/curl/README.en.md @@ -0,0 +1,41 @@ +# Instructions + +## 1. Introduction + +This API provides PAM asset account viewing service, supports RESTful style calls, and returns data in JSON format. + +## 2. Environment Requirements + +- `cURL` + +## 3. Usage + +**Request Method**: `GET api/v1/accounts/integration-applications/account-secret/` + +**Request Parameters** + +| Parameter Name | Type | Required | Description | +|----------------|------|----------|-------------------| +| asset | str | Yes | Asset ID / Name | +| account | str | Yes | Account ID / Name | + +**响应示例**: +```json +{ + "id": "72b0b0aa-ad82-4182-a631-ae4865e8ae0e", + "secret": "123456" +} +``` + +## Frequently Asked Questions (FAQ) + +Q: How to obtain the API Key? + +A: You can create an application in PAM - Application Management to generate KEY_ID and KEY_SECRET. + +## Changelog + + +| Version | Changes | Date | +|---------|------------------------|------------| +| 1.0.0 | Initial version | 2025-02-11 | diff --git a/apps/accounts/demos/curl/README.ja.md b/apps/accounts/demos/curl/README.ja.md new file mode 100644 index 000000000..e9a32ced7 --- /dev/null +++ b/apps/accounts/demos/curl/README.ja.md @@ -0,0 +1,42 @@ +# 使用方法 + +## 1. 概要 + +本 API は PAM 資産アカウントサービスの表示を提供し、RESTful スタイルの呼び出しをサポートし、データは JSON 形式で返されます。 + +## 2. 環境要件 + +- `cURL` + +## 3. 使用方法 + +**リクエスト方法**: `GET api/v1/accounts/integration-applications/account-secret/` + +**リクエストパラメータ** + +| パラメータ名 | タイプ | 必須 | 説明 | +|-------------|------|----|----------------| +| asset | str | はい | 資産 ID / 資産名 | +| account | str | はい | アカウント ID / アカウント名 | + + +**レスポンス例**: +```json +{ + "id": "72b0b0aa-ad82-4182-a631-ae4865e8ae0e", + "secret": "123456" +} +``` + +## よくある質問(FAQ) + +Q: APIキーはどのように取得しますか? + +A: PAM - アプリケーション管理でアプリケーションを作成し、KEY_IDとKEY_SECRETを生成できます。 + +## バージョン履歴(Changelog) + + +| バージョン | 変更内容 | 日付 | +| -------- | ----------------- |------------| +| 1.0.0 | 初期バージョン | 2025-02-11 | diff --git a/apps/accounts/demos/curl/README.zh-hans.md b/apps/accounts/demos/curl/README.zh-hans.md new file mode 100644 index 000000000..0df2adeff --- /dev/null +++ b/apps/accounts/demos/curl/README.zh-hans.md @@ -0,0 +1,40 @@ +# 使用说明 + +## 1. 简介 + +本 API 提供了 PAM 查看资产账号服务,支持 RESTful 风格的调用,返回数据采用 JSON 格式。 + +## 2. 环境要求 + +- `cURL` + +## 3. 使用方法 + +**请求方式**: `GET api/v1/accounts/integration-applications/account-secret/` + +**请求参数** + +| 参数名 | 类型 | 必填 | 说明 | +|----------|------|-----|---------------| +| asset | str | 是 | 资产 ID / 资产名称 | +| account | str | 是 | 账号 ID / 账号名称 | + +**响应示例**: +```json +{ + "id": "72b0b0aa-ad82-4182-a631-ae4865e8ae0e", + "secret": "123456" +} +``` + +## 常见问题(FAQ) + +Q: API Key 如何获取? + +A: 你可以在 PAM - 应用管理 创建应用生成 KEY_ID 和 KEY_SECRET。 + +## 版本历史(Changelog) + +| 版本号 | 变更内容 | 日期 | +| ----- | ----------------- |------------| +| 1.0.0 | 初始版本 | 2025-02-11 | diff --git a/apps/accounts/demos/curl/README.zh-hant.md b/apps/accounts/demos/curl/README.zh-hant.md new file mode 100644 index 000000000..414f821f6 --- /dev/null +++ b/apps/accounts/demos/curl/README.zh-hant.md @@ -0,0 +1,39 @@ +## 1. 簡介 + +本 API 提供了 PAM 查看資產賬號服務,支持 RESTful 風格的調用,返回數據採用 JSON 格式。 + +## 2. 環境要求 + +- `cURL` + +## 3. 使用方法 + +**請求方式**: `GET api/v1/accounts/integration-applications/account-secret/` + +**請求參數** + +| 參數名 | 類型 | 必填 | 說明 | +|----------|------|-----|---------------| +| asset | str | 是 | 資產 ID / 資產名稱 | +| account | str | 是 | 賬號 ID / 賬號名稱 | + +**响应示例**: +```json +{ + "id": "72b0b0aa-ad82-4182-a631-ae4865e8ae0e", + "secret": "123456" +} +``` + +## 常見問題(FAQ) + +Q: API Key 如何獲取? + +A: 你可以在 PAM - 應用管理 創建應用生成 KEY_ID 和 KEY_SECRET。 + +## 版本歷史(Changelog) + + +| 版本號 | 變更內容 | 日期 | +| ----- | ----------------- |------------| +| 1.0.0 | 初始版本 | 2025-02-11 | \ No newline at end of file diff --git a/apps/accounts/demos/curl/demo.sh b/apps/accounts/demos/curl/demo.sh new file mode 100644 index 000000000..de1efa4b9 --- /dev/null +++ b/apps/accounts/demos/curl/demo.sh @@ -0,0 +1,37 @@ +#!/bin/bash + +# 配置参数 +API_URL=${API_URL:-"http://127.0.0.1:8080"} +KEY_ID=${API_KEY_ID:-"72b0b0aa-ad82-4182-a631-ae4865e8ae0e"} +KEY_SECRET=${API_KEY_SECRET:-"6fuSO7P1m4cj8SSlgaYdblOjNAmnxDVD7tr8"} +ORG_ID=${ORG_ID:-"00000000-0000-0000-0000-000000000002"} + +# 查询参数 +ASSET="ubuntu_docker" +ACCOUNT="root" +QUERY_STRING="asset=${ASSET}&account=${ACCOUNT}" + +# 计算时间戳 +DATE=$(date -u +"%a, %d %b %Y %H:%M:%S GMT") + +# 计算 (request-target) 需要包含查询参数 +REQUEST_TARGET="get /api/v1/accounts/integration-applications/account-secret/?${QUERY_STRING}" + +# 生成签名字符串 +SIGNING_STRING="(request-target): $REQUEST_TARGET +accept: application/json +date: $DATE +x-jms-org: $ORG_ID" + +# 计算 HMAC-SHA256 签名 +SIGNATURE=$(echo -n "$SIGNING_STRING" | openssl dgst -sha256 -hmac "$KEY_SECRET" -binary | base64) + +# 发送请求 +curl -G "$API_URL/api/v1/accounts/integration-applications/account-secret/" \ + -H "Accept: application/json" \ + -H "Date: $DATE" \ + -H "X-JMS-ORG: $ORG_ID" \ + -H "X-Source: jms-pam" \ + -H "Authorization: Signature keyId=\"$KEY_ID\",algorithm=\"hmac-sha256\",headers=\"(request-target) accept date x-jms-org\",signature=\"$SIGNATURE\"" \ + --data-urlencode "asset=$ASSET" \ + --data-urlencode "account=$ACCOUNT" \ No newline at end of file diff --git a/apps/accounts/demos/go/README.en.md b/apps/accounts/demos/go/README.en.md new file mode 100644 index 000000000..c72ed9922 --- /dev/null +++ b/apps/accounts/demos/go/README.en.md @@ -0,0 +1,45 @@ +# Instructions + +## 1. Introduction + +This API provides the PAM asset account service, supports RESTful style calls, and returns data in JSON format. + +## 2. Environment Requirements + +- `Go 1.16+` +- `crypto/hmac` +- `crypto/sha256` +- `encoding/base64` +- `net/http` + +## 3. Usage + +**Request Method**: `GET api/v1/accounts/integration-applications/account-secret/` + +**Request Parameters** + +| Parameter Name | Type | Required | Description | +|----------------|------|----------|-------------------| +| asset | str | Yes | Asset ID / Asset Name | +| account | str | Yes | Account ID / Account Name | + +**Response Example**: +```json +{ + "id": "72b0b0aa-ad82-4182-a631-ae4865e8ae0e", + "secret": "123456" +} +``` + +## Frequently Asked Questions (FAQ) + +Q: How to obtain the API Key? + +A: You can create an application in PAM - Application Management to generate KEY_ID and KEY_SECRET. + +## Changelog + + +| Version | Changes | Date | +|---------|------------------------|------------| +| 1.0.0 | Initial version | 2025-02-11 | diff --git a/apps/accounts/demos/go/README.ja.md b/apps/accounts/demos/go/README.ja.md new file mode 100644 index 000000000..da50b1203 --- /dev/null +++ b/apps/accounts/demos/go/README.ja.md @@ -0,0 +1,45 @@ +# 使用方法 + +## 1. 概要 + +このAPIは、PAMの資産アカウントサービスの表示を提供し、RESTfulスタイルの呼び出しをサポートし、データはJSON形式で返されます。 + +## 2. 環境要件 + +- `Go 1.16+` +- `crypto/hmac` +- `crypto/sha256` +- `encoding/base64` +- `net/http` + +## 3. 使用方法 + +**リクエスト方法**: `GET api/v1/accounts/integration-applications/account-secret/` + +**リクエストパラメータ** + +| パラメータ名 | タイプ | 必須 | 説明 | +|-------------|-------|----|--------------| +| asset | str | はい | 資産ID / 資産名 | +| account | str | はい | アカウントID / アカウント名 | + +**レスポンス例**: +```json +{ + "id": "72b0b0aa-ad82-4182-a631-ae4865e8ae0e", + "secret": "123456" +} +``` + +## よくある質問(FAQ) + +Q: APIキーはどのように取得しますか? + +A: PAM - アプリケーション管理でアプリケーションを作成し、KEY_IDとKEY_SECRETを生成できます。 + +## バージョン履歴(Changelog) + + +| バージョン | 変更内容 | 日付 | +| -------- | ----------------- |------------| +| 1.0.0 | 初期バージョン | 2025-02-11 | 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..2a9747b7b --- /dev/null +++ b/apps/accounts/demos/go/README.zh-hans.md @@ -0,0 +1,45 @@ +# 使用说明 + +## 1. 简介 + +本 API 提供了 PAM 查看资产账号服务,支持 RESTful 风格的调用,返回数据采用 JSON 格式。 + +## 2. 环境要求 + +- `Go 1.16+` +- `crypto/hmac` +- `crypto/sha256` +- `encoding/base64` +- `net/http` + +## 3. 使用方法 + +**请求方式**: `GET api/v1/accounts/integration-applications/account-secret/` + +**请求参数** + +| 参数名 | 类型 | 必填 | 说明 | +|----------|------|-----|---------------| +| asset | str | 是 | 资产 ID / 资产名称 | +| account | str | 是 | 账号 ID / 账号名称 | + +**响应示例**: +```json +{ + "id": "72b0b0aa-ad82-4182-a631-ae4865e8ae0e", + "secret": "123456" +} +``` + +## 常见问题(FAQ) + +Q: API Key 如何获取? + +A: 你可以在 PAM - 应用管理 创建应用生成 KEY_ID 和 KEY_SECRET。 + +## 版本历史(Changelog) + + +| 版本号 | 变更内容 | 日期 | +| ----- | ----------------- |------------| +| 1.0.0 | 初始版本 | 2025-02-11 | \ No newline at end of file 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..0d4c44e3e --- /dev/null +++ b/apps/accounts/demos/go/README.zh-hant.md @@ -0,0 +1,153 @@ +## 1. 簡介 + +本 API 提供了 PAM 查看資產賬號服務,支持 RESTful 風格的調用,返回數據採用 JSON 格式。 + +## 2. 環境要求 + +- `Go 1.16+` +- `crypto/hmac` +- `crypto/sha256` +- `encoding/base64` +- `net/http` + +## 3. 使用方法 + +**請求方式**: `GET api/v1/accounts/integration-applications/account-secret/` + +**請求參數** + +| 參數名 | 類型 | 必填 | 說明 | +|----------|------|-----|---------------| +| asset | str | 是 | 資產 ID / 資產名稱 | +| account | str | 是 | 賬號 ID / 賬號名稱 | + +**響應示例**: +```json +{ + "id": "72b0b0aa-ad82-4182-a631-ae4865e8ae0e", + "secret": "123456" +} +``` + +## 常見問題(FAQ) + +Q: API Key 如何獲取? + +A: 你可以在 PAM - 應用管理 創建應用生成 KEY_ID 和 KEY_SECRET。 + +## 版本歷史(Changelog) + + +| 版本號 | 變更內容 | 日期 | +| ----- | ----------------- |------------| +| 1.0.0 | 初始版本 | 2025-02-11 | + +## 使用方法 + +### 初始化 + +要使用 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/demo.go b/apps/accounts/demos/go/demo.go new file mode 100644 index 000000000..e27f26aad --- /dev/null +++ b/apps/accounts/demos/go/demo.go @@ -0,0 +1,119 @@ +package main + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "log" + "net/http" + "net/url" + "os" + "strings" + "time" +) + +type APIClient struct { + Client *http.Client + APIURL string + KeyID string + KeySecret string + OrgID string +} + +func NewAPIClient() *APIClient { + return &APIClient{ + Client: &http.Client{}, + APIURL: getEnv("API_URL", "http://127.0.0.1:8080"), + KeyID: getEnv("API_KEY_ID", "72b0b0aa-ad82-4182-a631-ae4865e8ae0e"), + KeySecret: getEnv("API_KEY_SECRET", "6fuSO7P1m4cj8SSlgaYdblOjNAmnxDVD7tr8"), + OrgID: getEnv("ORG_ID", "00000000-0000-0000-0000-000000000002"), + } +} + +func getEnv(key, defaultValue string) string { + value := os.Getenv(key) + if value == "" { + return defaultValue + } + return value +} + +func (c *APIClient) GetAccountSecret(asset, account string) (map[string]interface{}, error) { + u, err := url.Parse(c.APIURL) + if err != nil { + return nil, fmt.Errorf("failed to parse API URL: %v", err) + } + u.Path = "/api/v1/accounts/integration-applications/account-secret/" + + q := u.Query() + q.Add("asset", asset) + q.Add("account", account) + u.RawQuery = q.Encode() + + req, err := http.NewRequest("GET", u.String(), nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %v", err) + } + + date := time.Now().UTC().Format("Mon, 02 Jan 2006 15:04:05 GMT") + req.Header.Set("Accept", "application/json") + req.Header.Set("X-JMS-ORG", c.OrgID) + req.Header.Set("Date", date) + req.Header.Set("X-Source", "jms-pam") + + headersList := []string{"(request-target)", "accept", "date", "x-jms-org"} + var signatureParts []string + + for _, h := range headersList { + var value string + if h == "(request-target)" { + value = strings.ToLower(req.Method) + " " + req.URL.RequestURI() + } else { + canonicalKey := http.CanonicalHeaderKey(h) + value = req.Header.Get(canonicalKey) + } + signatureParts = append(signatureParts, fmt.Sprintf("%s: %s", h, value)) + } + + signatureString := strings.Join(signatureParts, "\n") + mac := hmac.New(sha256.New, []byte(c.KeySecret)) + mac.Write([]byte(signatureString)) + signatureB64 := base64.StdEncoding.EncodeToString(mac.Sum(nil)) + + headersJoined := strings.Join(headersList, " ") + authHeader := fmt.Sprintf( + `Signature keyId="%s",algorithm="hmac-sha256",headers="%s",signature="%s"`, + c.KeyID, + headersJoined, + signatureB64, + ) + req.Header.Set("Authorization", authHeader) + + resp, err := c.Client.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API returned non-200 status: %d", resp.StatusCode) + } + + var result map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode response: %v", err) + } + + return result, nil +} + +func main() { + client := NewAPIClient() + result, err := client.GetAccountSecret("ubuntu_docker", "root") + if err != nil { + log.Fatalf("Error: %v", err) + } + fmt.Printf("Result: %+v\n", result) +} 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/java/Demo.java b/apps/accounts/demos/java/Demo.java new file mode 100644 index 000000000..ca15aeaa7 --- /dev/null +++ b/apps/accounts/demos/java/Demo.java @@ -0,0 +1,78 @@ +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Base64; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.net.URLEncoder; + +public class Demo { + private static final String API_URL = System.getenv().getOrDefault("API_URL", "http://127.0.0.1:8080"); + private static final String KEY_ID = System.getenv().getOrDefault("API_KEY_ID", "72b0b0aa-ad82-4182-a631-ae4865e8ae0e"); + private static final String KEY_SECRET = System.getenv().getOrDefault("API_KEY_SECRET", "6fuSO7P1m4cj8SSlgaYdblOjNAmnxDVD7tr8"); + private static final String ORG_ID = System.getenv().getOrDefault("ORG_ID", "00000000-0000-0000-0000-000000000002"); + + public static void main(String[] args) throws Exception { + APIClient client = new APIClient(); + String result = client.getAccountSecret("ubuntu_docker", "root"); + System.out.println(result); + } + + static class APIClient { + private final HttpClient httpClient = HttpClient.newHttpClient(); + + public String getAccountSecret(String asset, String account) throws Exception { + // 编码 URL 参数 + String queryString = "asset=" + URLEncoder.encode(asset, StandardCharsets.UTF_8) + + "&account=" + URLEncoder.encode(account, StandardCharsets.UTF_8); + + // 完整的 URL(带参数) + String url = API_URL + "/api/v1/accounts/integration-applications/account-secret/?" + queryString; + + // 获取当前 UTC 时间 + String date = ZonedDateTime.now().format(DateTimeFormatter.RFC_1123_DATE_TIME); + + // 构造 (request-target),包括查询参数 + String requestTarget = "get /api/v1/accounts/integration-applications/account-secret/?" + queryString; + + // 生成签名字符串 + String signingString = "(request-target): " + requestTarget + "\n" + + "accept: application/json\n" + + "date: " + date + "\n" + + "x-jms-org: " + ORG_ID; + String signature = sign(signingString, KEY_SECRET); + + // 构造 HTTP 请求 + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Accept", "application/json") + .header("Date", date) + .header("X-JMS-ORG", ORG_ID) + .header("X-Source", "jms-pam") + .header("Authorization", "Signature keyId=\"" + KEY_ID + "\",algorithm=\"hmac-sha256\",headers=\"(request-target) accept date x-jms-org\",signature=\"" + signature + "\"") + .build(); + + // 发送请求 + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() == 200) { + return response.body(); + } else { + System.err.println("API 请求失败: " + response.statusCode()); + return null; + } + } + + // HMAC-SHA256 签名计算 + private String sign(String data, String key) throws Exception { + Mac mac = Mac.getInstance("HmacSHA256"); + SecretKeySpec secretKeySpec = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); + mac.init(secretKeySpec); + byte[] rawHmac = mac.doFinal(data.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(rawHmac); + } + } +} \ No newline at end of file diff --git a/apps/accounts/demos/java/README.en.md b/apps/accounts/demos/java/README.en.md new file mode 100644 index 000000000..8e0375148 --- /dev/null +++ b/apps/accounts/demos/java/README.en.md @@ -0,0 +1,42 @@ +# Instructions + +## 1. Introduction + +This API provides PAM asset account viewing service, supports RESTful style calls, and returns data in JSON format. + +## 2. Environment Requirements + +- `Java 8+` +- `HttpClient` + +## 3. Usage + +**Request Method**: `GET api/v1/accounts/integration-applications/account-secret/` + +**Request Parameters** + +| Parameter Name | Type | Required | Description | +|----------------|------|----------|-------------------| +| asset | str | Yes | Asset ID / Name | +| account | str | Yes | Account ID / Name | + +**Response Example**: +```json +{ + "id": "72b0b0aa-ad82-4182-a631-ae4865e8ae0e", + "secret": "123456" +} +``` + +## Frequently Asked Questions (FAQ) + +Q: How to obtain the API Key? + +A: You can create an application in PAM - Application Management to generate KEY_ID and KEY_SECRET. + +## Changelog + + +| Version | Changes | Date | +|---------|------------------------|------------| +| 1.0.0 | Initial version | 2025-02-11 | diff --git a/apps/accounts/demos/java/README.ja.md b/apps/accounts/demos/java/README.ja.md new file mode 100644 index 000000000..ad4ea63e2 --- /dev/null +++ b/apps/accounts/demos/java/README.ja.md @@ -0,0 +1,42 @@ +# 使用方法 + +## 1. 概要 + +本 API は PAM 資産アカウントサービスの表示を提供し、RESTful スタイルの呼び出しをサポートし、データは JSON 形式で返されます。 + +## 2. 環境要件 + +- `Java 8+` +- `HttpClient` + +## 3. 使用方法 + +**リクエスト方法**: `GET api/v1/accounts/integration-applications/account-secret/` + +**リクエストパラメータ** + +| パラメータ名 | タイプ | 必須 | 説明 | +|-------------|------|----|----------------| +| asset | str | はい | 資産 ID / 資産名 | +| account | str | はい | アカウント ID / アカウント名 | + +**レスポンス例**: +```json +{ + "id": "72b0b0aa-ad82-4182-a631-ae4865e8ae0e", + "secret": "123456" +} +``` + +## よくある質問(FAQ) + +Q: APIキーはどのように取得しますか? + +A: PAM - アプリケーション管理でアプリケーションを作成し、KEY_IDとKEY_SECRETを生成できます。 + +## バージョン履歴(Changelog) + + +| バージョン | 変更内容 | 日付 | +| -------- | ----------------- |------------| +| 1.0.0 | 初期バージョン | 2025-02-11 | diff --git a/apps/accounts/demos/java/README.zh-hans.md b/apps/accounts/demos/java/README.zh-hans.md new file mode 100644 index 000000000..d4581a9ac --- /dev/null +++ b/apps/accounts/demos/java/README.zh-hans.md @@ -0,0 +1,41 @@ +# 使用说明 + +## 1. 简介 + +本 API 提供了 PAM 查看资产账号服务,支持 RESTful 风格的调用,返回数据采用 JSON 格式。 + +## 2. 环境要求 + +- `Java 8+` +- `HttpClient` + +## 3. 使用方法 + +**请求方式**: `GET api/v1/accounts/integration-applications/account-secret/` + +**请求参数** + +| 参数名 | 类型 | 必填 | 说明 | +|----------|------|-----|---------------| +| asset | str | 是 | 资产 ID / 资产名称 | +| account | str | 是 | 账号 ID / 账号名称 | + +**响应示例**: +```json +{ + "id": "72b0b0aa-ad82-4182-a631-ae4865e8ae0e", + "secret": "123456" +} +``` + +## 常见问题(FAQ) + +Q: API Key 如何获取? + +A: 你可以在 PAM - 应用管理 创建应用生成 KEY_ID 和 KEY_SECRET。 + +## 版本历史(Changelog) + +| 版本号 | 变更内容 | 日期 | +| ----- | ----------------- |------------| +| 1.0.0 | 初始版本 | 2025-02-11 | diff --git a/apps/accounts/demos/java/README.zh-hant.md b/apps/accounts/demos/java/README.zh-hant.md new file mode 100644 index 000000000..693a8e1ea --- /dev/null +++ b/apps/accounts/demos/java/README.zh-hant.md @@ -0,0 +1,40 @@ +## 1. 簡介 + +本 API 提供了 PAM 查看資產賬號服務,支持 RESTful 風格的調用,返回數據採用 JSON 格式。 + +## 2. 環境要求 + +- `Java 8+` +- `HttpClient` + +## 3. 使用方法 + +**請求方式**: `GET api/v1/accounts/integration-applications/account-secret/` + +**請求參數** + +| 參數名 | 類型 | 必填 | 說明 | +|----------|------|-----|---------------| +| asset | str | 是 | 資產 ID / 資產名稱 | +| account | str | 是 | 賬號 ID / 賬號名稱 | + +**響應示例**: +```json +{ + "id": "72b0b0aa-ad82-4182-a631-ae4865e8ae0e", + "secret": "123456" +} +``` + +## 常見問題(FAQ) + +Q: API Key 如何獲取? + +A: 你可以在 PAM - 應用管理 創建應用生成 KEY_ID 和 KEY_SECRET。 + +## 版本歷史(Changelog) + + +| 版本號 | 變更內容 | 日期 | +| ----- | ----------------- |------------| +| 1.0.0 | 初始版本 | 2025-02-11 | \ No newline at end of file diff --git a/apps/accounts/demos/node/README.en.md b/apps/accounts/demos/node/README.en.md new file mode 100644 index 000000000..93f431237 --- /dev/null +++ b/apps/accounts/demos/node/README.en.md @@ -0,0 +1,43 @@ +# Instructions + +## 1. Introduction + +This API provides PAM asset account viewing service, supports RESTful style calls, and returns data in JSON format. + +## 2. Environment Requirements + +- `Node.js 16+` +- `axios ^1.7.9` +- `moment ^2.30.1` + +## 3. Usage + +**Request Method**: `GET api/v1/accounts/integration-applications/account-secret/` + +**Request Parameters** + +| Parameter Name | Type | Required | Description | +|----------------|------|----------|-------------------| +| asset | str | Yes | Asset ID / Name | +| account | str | Yes | Account ID / Name | + +**Response Example**: +```json +{ + "id": "72b0b0aa-ad82-4182-a631-ae4865e8ae0e", + "secret": "123456" +} +``` + +## Frequently Asked Questions (FAQ) + +Q: How to obtain the API Key? + +A: You can create an application in PAM - Application Management to generate KEY_ID and KEY_SECRET. + +## Changelog + + +| Version | Changes | Date | +|---------|------------------------|------------| +| 1.0.0 | Initial version | 2025-02-11 | diff --git a/apps/accounts/demos/node/README.ja.md b/apps/accounts/demos/node/README.ja.md new file mode 100644 index 000000000..4916fb0e8 --- /dev/null +++ b/apps/accounts/demos/node/README.ja.md @@ -0,0 +1,41 @@ +# 使用方法 + +## 1. 概要 + +本 API は PAM 資産アカウントサービスの表示を提供し、RESTful スタイルの呼び出しをサポートし、データは JSON 形式で返されます。 + +## 2. 環境要件 + +- `Node.js 16+` +- `axios ^1.7.9` +- `moment ^2.30.1` + +## 3. 使用方法 + +**リクエスト方法**: `GET api/v1/accounts/integration-applications/account-secret/` + +**リクエストパラメータ** + +| パラメータ名 | タイプ | 必須 | 説明 | +|-------------|------|----|----------------| +| asset | str | はい | 資産 ID / 資産名 | +| account | str | はい | アカウント ID / アカウント名 | + +**レスポンス例**: +```json +{ + "id": "72b0b0aa-ad82-4182-a631-ae4865e8ae0e", + "secret": "123456" +} +``` +よくある質問(FAQ) + +Q: API キーはどのように取得しますか? + +A: PAM - アプリケーション管理でアプリケーションを作成し、KEY_ID と KEY_SECRET を生成できます。 + +バージョン履歴(Changelog) + +| バージョン | 変更内容 | 日付 | +| ----- | ----------------- |------------| +| 1.0.0 | 初始版本 | 2025-02-11 | \ No newline at end of file diff --git a/apps/accounts/demos/node/README.zh-hans.md b/apps/accounts/demos/node/README.zh-hans.md new file mode 100644 index 000000000..42f0b68bf --- /dev/null +++ b/apps/accounts/demos/node/README.zh-hans.md @@ -0,0 +1,42 @@ +# 使用说明 + +## 1. 简介 + +本 API 提供了 PAM 查看资产账号服务,支持 RESTful 风格的调用,返回数据采用 JSON 格式。 + +## 2. 环境要求 + +- `Node.js 16+` +- `axios ^1.7.9` +- `moment ^2.30.1` + +## 3. 使用方法 + +**请求方式**: `GET api/v1/accounts/integration-applications/account-secret/` + +**请求参数** + +| 参数名 | 类型 | 必填 | 说明 | +|----------|------|-----|---------------| +| asset | str | 是 | 资产 ID / 资产名称 | +| account | str | 是 | 账号 ID / 账号名称 | + +**响应示例**: +```json +{ + "id": "72b0b0aa-ad82-4182-a631-ae4865e8ae0e", + "secret": "123456" +} +``` + +## 常见问题(FAQ) + +Q: API Key 如何获取? + +A: 你可以在 PAM - 应用管理 创建应用生成 KEY_ID 和 KEY_SECRET。 + +## 版本历史(Changelog) + +| 版本号 | 变更内容 | 日期 | +| ----- | ----------------- |------------| +| 1.0.0 | 初始版本 | 2025-02-11 | diff --git a/apps/accounts/demos/node/README.zh-hant.md b/apps/accounts/demos/node/README.zh-hant.md new file mode 100644 index 000000000..92d683bf4 --- /dev/null +++ b/apps/accounts/demos/node/README.zh-hant.md @@ -0,0 +1,41 @@ +## 1. 簡介 + +本 API 提供了 PAM 查看資產賬號服務,支持 RESTful 風格的調用,返回數據採用 JSON 格式。 + +## 2. 環境要求 + +- `Node.js 16+` +- `axios ^1.7.9` +- `moment ^2.30.1` + +## 3. 使用方法 + +**請求方式**: `GET api/v1/accounts/integration-applications/account-secret/` + +**請求參數** + +| 參數名 | 類型 | 必填 | 說明 | +|----------|------|-----|---------------| +| asset | str | 是 | 資產 ID / 資產名稱 | +| account | str | 是 | 賬號 ID / 賬號名稱 | + +**響應示例**: +```json +{ + "id": "72b0b0aa-ad82-4182-a631-ae4865e8ae0e", + "secret": "123456" +} +``` + +## 常見問題(FAQ) + +Q: API Key 如何獲取? + +A: 你可以在 PAM - 應用管理 創建應用生成 KEY_ID 和 KEY_SECRET。 + +## 版本歷史(Changelog) + + +| 版本號 | 變更內容 | 日期 | +| ----- | ----------------- |------------| +| 1.0.0 | 初始版本 | 2025-02-11 | \ No newline at end of file diff --git a/apps/accounts/demos/node/demo.js b/apps/accounts/demos/node/demo.js new file mode 100644 index 000000000..db8afcd4b --- /dev/null +++ b/apps/accounts/demos/node/demo.js @@ -0,0 +1,56 @@ +const axios = require('axios'); +const crypto = require('crypto'); +const moment = require('moment'); + +const API_URL = process.env.API_URL || "http://127.0.0.1:8080"; +const KEY_ID = process.env.API_KEY_ID || "72b0b0aa-ad82-4182-a631-ae4865e8ae0e"; +const KEY_SECRET = process.env.API_KEY_SECRET || "6fuSO7P1m4cj8SSlgaYdblOjNAmnxDVD7tr8"; +const ORG_ID = process.env.ORG_ID || "00000000-0000-0000-0000-000000000002"; + +class APIClient { + constructor() { + this.apiUrl = API_URL; + this.keyId = KEY_ID; + this.keySecret = KEY_SECRET; + this.orgId = ORG_ID; + } + + signRequest(method, url, params, headers) { + const date = moment().utc().format('ddd, DD MMM YYYY HH:mm:ss [GMT]'); + const queryString = Object.keys(params).length ? `?${new URLSearchParams(params).toString()}` : ""; + const requestTarget = `${method.toLowerCase()} ${url}${queryString}`; + headers['Date'] = date; + headers['X-JMS-ORG'] = this.orgId; + const signingString = `(request-target): ${requestTarget}\naccept: application/json\ndate: ${date}\nx-jms-org: ${this.orgId}`; + const signature = crypto.createHmac('sha256', this.keySecret).update(signingString).digest('base64'); + headers['Authorization'] = `Signature keyId="${this.keyId}",algorithm="hmac-sha256",headers="(request-target) accept date x-jms-org",signature="${signature}"`; + } + + async getAccountSecret(asset, account) { + const url = `/api/v1/accounts/integration-applications/account-secret/`; + const params = { asset: asset, account: account }; + const headers = { + 'Accept': 'application/json', + 'X-Source': 'jms-pam' + }; + this.signRequest('GET', url, params, headers); + + try { + const response = await axios.get(`${this.apiUrl}${url}`, { + headers: headers, + params: params, + timeout: 10000 + }); + return response.data; + } catch (error) { + console.error(`API 请求失败: ${error}`); + return null; + } + } +} + +(async () => { + const client = new APIClient(); + const result = await client.getAccountSecret("ubuntu_docker", "root"); + console.log(result); +})(); diff --git a/apps/accounts/demos/python/README.en.md b/apps/accounts/demos/python/README.en.md new file mode 100644 index 000000000..a479b35e9 --- /dev/null +++ b/apps/accounts/demos/python/README.en.md @@ -0,0 +1,42 @@ +# Instructions + +## 1. Introduction + +This API provides the PAM asset account service, supports RESTful style calls, and returns data in JSON format. + +## 2. Environment Requirements + +- `Python 3.11+` +- `requests==2.31.0` +- `httpsig==1.3.0` + +## 3. Usage +**Request Method**: `GET api/v1/accounts/integration-applications/account-secret/` + +**Request Parameters** + +| Parameter Name | Type | Required | Description | +|----------------|------|----------|-------------------| +| asset | str | Yes | Asset ID / Asset Name | +| account | str | Yes | Account ID / Account Name | + +**Response Example**: +```json +{ + "id": "72b0b0aa-ad82-4182-a631-ae4865e8ae0e", + "secret": "123456" +} +``` + +## Frequently Asked Questions (FAQ) + +Q: How to obtain the API Key? + +A: You can create an application in PAM - Application Management to generate KEY_ID and KEY_SECRET. + +## Changelog + + +| Version | Changes | Date | +|---------|------------------------|------------| +| 1.0.0 | Initial version | 2025-02-11 | diff --git a/apps/accounts/demos/python/README.ja.md b/apps/accounts/demos/python/README.ja.md new file mode 100644 index 000000000..ec0b357bc --- /dev/null +++ b/apps/accounts/demos/python/README.ja.md @@ -0,0 +1,42 @@ +# 使用方法 + +## 1. 概要 + +このAPIは、PAMの資産アカウントサービスの表示を提供し、RESTfulスタイルの呼び出しをサポートし、データはJSON形式で返されます。 + +## 2. 環境要件 + +- `Python 3.11+` +- `requests==2.31.0` +- `httpsig==1.3.0` + +## 3. 使用方法 +**リクエスト方法**: `GET api/v1/accounts/integration-applications/account-secret/` + +**リクエストパラメータ** + +| パラメータ名 | タイプ | 必須 | 説明 | +|-------------|-------|----|--------------| +| asset | str | はい | 資産ID / 資産名 | +| account | str | はい | アカウントID / アカウント名 | + +**レスポンス例**: +```json +{fi + "id": "72b0b0aa-ad82-4182-a631-ae4865e8ae0e", + "secret": "123456" +} +``` + +## よくある質問(FAQ) + +Q: APIキーはどのように取得しますか? + +A: PAM - アプリケーション管理でアプリケーションを作成し、KEY_IDとKEY_SECRETを生成できます。 + +## バージョン履歴(Changelog) + + +| バージョン | 変更内容 | 日付 | +| -------- | ----------------- |------------| +| 1.0.0 | 初期バージョン | 2025-02-11 | 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..4d88ecf84 --- /dev/null +++ b/apps/accounts/demos/python/README.zh-hans.md @@ -0,0 +1,42 @@ +# 使用说明 + +## 1. 简介 + +本 API 提供了 PAM 查看资产账号服务,支持 RESTful 风格的调用,返回数据采用 JSON 格式。 + +## 2. 环境要求 + +- `Python 3.11+` +- `requests==2.31.0` +- `httpsig==1.3.0` + +## 3. 使用方法 +**请求方式**: `GET api/v1/accounts/integration-applications/account-secret/` + +**请求参数** + +| 参数名 | 类型 | 必填 | 说明 | +|------------|------|----|--------------| +| asset | str | 是 | 资产 ID / 资产名称 | +| account | str | 是 | 账号 ID / 账号名称 | + +**响应示例**: +```json +{ + "id": "72b0b0aa-ad82-4182-a631-ae4865e8ae0e", + "secret": "123456" +} +``` + +## 常见问题(FAQ) + +Q: API Key 如何获取? + +A: 你可以在 PAM - 应用管理 创建应用生成 KEY_ID 和 KEY_SECRET。 + +## 版本历史(Changelog) + + +| 版本号 | 变更内容 | 日期 | +| ----- | ----------------- |------------| +| 1.0.0 | 初始版本 | 2025-02-11 | 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..b899d3bda --- /dev/null +++ b/apps/accounts/demos/python/README.zh-hant.md @@ -0,0 +1,42 @@ +# 使用說明 + +## 1. 簡介 + +本 API 提供了 PAM 查看資產賬號服務,支持 RESTful 風格的調用,返回數據採用 JSON 格式。 + +## 2. 環境要求 + +- `Python 3.11+` +- `requests==2.31.0` +- `httpsig==1.3.0` + +## 3. 使用方法 +**請求方式**: `GET api/v1/accounts/integration-applications/account-secret/` + +**請求參數** + +| 參數名 | 類型 | 必填 | 說明 | +|------------|------|----|--------------| +| asset | str | 是 | 資產 ID / 資產名稱 | +| account | str | 是 | 賬號 ID / 賬號名稱 | + +**響應示例**: +```json +{ + "id": "72b0b0aa-ad82-4182-a631-ae4865e8ae0e", + "secret": "123456" +} +``` + +## 常見問題(FAQ) + +Q: API Key 如何獲取? + +A: 你可以在 PAM - 應用管理 創建應用生成 KEY_ID 和 KEY_SECRET。 + +## 版本歷史(Changelog) + + +| 版本號 | 變更內容 | 日期 | +| ----- | ----------------- |------------| +| 1.0.0 | 初始版本 | 2025-02-11 | diff --git a/apps/accounts/demos/python/demo.py b/apps/accounts/demos/python/demo.py new file mode 100644 index 000000000..5d7f9d4b6 --- /dev/null +++ b/apps/accounts/demos/python/demo.py @@ -0,0 +1,44 @@ +# 示例调用 + +import requests +import os +from datetime import datetime +from httpsig.requests_auth import HTTPSignatureAuth + +API_URL = os.getenv("API_URL", "http://127.0.0.1:8080") +KEY_ID = os.getenv("API_KEY_ID", "72b0b0aa-ad82-4182-a631-ae4865e8ae0e") +KEY_SECRET = os.getenv("API_KEY_SECRET", "6fuSO7P1m4cj8SSlgaYdblOjNAmnxDVD7tr8") +ORG_ID = os.getenv("ORG_ID", "00000000-0000-0000-0000-000000000002") + + +class APIClient: + def __init__(self): + self.session = requests.Session() + self.auth = HTTPSignatureAuth( + key_id=KEY_ID, secret=KEY_SECRET, + algorithm='hmac-sha256', headers=['(request-target)', 'accept', 'date', 'x-jms-org'] + ) + + def get_account_secret(self, asset, account): + url = f"{API_URL}/api/v1/accounts/integration-applications/account-secret/" + headers = { + 'Accept': 'application/json', + 'X-JMS-ORG': ORG_ID, + 'Date': datetime.utcnow().strftime('%a, %d %b %Y %H:%M:%S GMT'), + 'X-Source': 'jms-pam' + } + params = {"asset": asset, "account": account} + + try: + response = self.session.get(url, auth=self.auth, headers=headers, params=params, timeout=10) + response.raise_for_status() + return response.json() + except requests.RequestException as e: + print(f"API 请求失败: {e}") + return None + + +if __name__ == "__main__": + client = APIClient() + result = client.get_account_secret(asset="ubuntu_docker", account="root") + print(result) \ No newline at end of file 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..e77a74755 --- /dev/null +++ b/apps/accounts/demos/python/jms_pam/main.py @@ -0,0 +1,148 @@ +import uuid +from datetime import datetime +from urllib.parse import urlencode + +import requests +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) + + def get_accounts(self): + pass 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/filters.py b/apps/accounts/filters.py index 622647079..019c9a93d 100644 --- a/apps/accounts/filters.py +++ b/apps/accounts/filters.py @@ -1,36 +1,116 @@ # -*- coding: utf-8 -*- # from django.db.models import Q +from django.utils import timezone from django_filters import rest_framework as drf_filters from assets.models import Node from common.drf.filters import BaseFilterSet -from .models import Account, GatheredAccount, ChangeSecretRecord +from common.utils.timezone import local_zero_hour, local_now +from .models import Account, GatheredAccount, ChangeSecretRecord, PushSecretRecord, IntegrationApplication class AccountFilterSet(BaseFilterSet): - ip = drf_filters.CharFilter(field_name='address', lookup_expr='exact') - hostname = drf_filters.CharFilter(field_name='name', lookup_expr='exact') - username = drf_filters.CharFilter(field_name="username", lookup_expr='exact') - address = drf_filters.CharFilter(field_name="asset__address", lookup_expr='exact') - asset_id = drf_filters.CharFilter(field_name="asset", lookup_expr='exact') - asset = drf_filters.CharFilter(field_name='asset', lookup_expr='exact') - assets = drf_filters.CharFilter(field_name='asset_id', lookup_expr='exact') - nodes = drf_filters.CharFilter(method='filter_nodes') - node_id = drf_filters.CharFilter(method='filter_nodes') - has_secret = drf_filters.BooleanFilter(method='filter_has_secret') - platform = drf_filters.CharFilter(field_name='asset__platform_id', lookup_expr='exact') - category = drf_filters.CharFilter(field_name='asset__platform__category', lookup_expr='exact') - type = drf_filters.CharFilter(field_name='asset__platform__type', lookup_expr='exact') + ip = drf_filters.CharFilter(field_name="address", lookup_expr="exact") + hostname = drf_filters.CharFilter(field_name="name", lookup_expr="exact") + username = drf_filters.CharFilter(field_name="username", lookup_expr="exact") + address = drf_filters.CharFilter(field_name="asset__address", lookup_expr="exact") + asset_id = drf_filters.CharFilter(field_name="asset", lookup_expr="exact") + asset = drf_filters.CharFilter(field_name="asset", lookup_expr="exact") + assets = drf_filters.CharFilter(field_name="asset_id", lookup_expr="exact") + nodes = drf_filters.CharFilter(method="filter_nodes") + node_id = drf_filters.CharFilter(method="filter_nodes") + has_secret = drf_filters.BooleanFilter(method="filter_has_secret") + platform = drf_filters.CharFilter( + field_name="asset__platform_id", lookup_expr="exact" + ) + category = drf_filters.CharFilter( + field_name="asset__platform__category", lookup_expr="exact" + ) + type = drf_filters.CharFilter( + field_name="asset__platform__type", lookup_expr="exact" + ) + latest_discovery = drf_filters.BooleanFilter(method="filter_latest") + latest_accessed = drf_filters.BooleanFilter(method="filter_latest") + latest_updated = drf_filters.BooleanFilter(method="filter_latest") + latest_secret_changed = drf_filters.BooleanFilter(method="filter_latest") + latest_secret_change_failed = drf_filters.BooleanFilter(method="filter_latest") + risk = drf_filters.CharFilter( + method="filter_risk", + ) + integrationapplication = drf_filters.CharFilter(method="filter_integrationapplication") + long_time_no_change_secret = drf_filters.BooleanFilter(method="filter_long_time") + long_time_no_verified = drf_filters.BooleanFilter(method="filter_long_time") @staticmethod def filter_has_secret(queryset, name, has_secret): - q = Q(secret__isnull=True) | Q(secret='') + q = Q(_secret__isnull=True) | Q(_secret="") if has_secret: return queryset.exclude(q) else: return queryset.filter(q) + @staticmethod + def filter_long_time(queryset, name, value): + date = timezone.now() - timezone.timedelta(days=30) + + if name == "long_time_no_change_secret": + field = "date_change_secret" + confirm_field = "change_secret_status" + else: + field = "date_verified" + confirm_field = "connectivity" + + q = Q(**{f"{field}__lt": date}) | Q(**{f"{field}__isnull": True}) + confirm_q = {f"{confirm_field}": "na"} + queryset = queryset.exclude(**confirm_q).filter(q) + return queryset + + @staticmethod + def filter_risk(queryset, name, value): + if not value: + return queryset + + queryset = queryset.filter(risks__risk=value) + return queryset + + @staticmethod + def filter_integrationapplication(queryset, name, value): + if not value: + return queryset + + integrationapplication = IntegrationApplication.objects.filter(pk=value).first() + if not integrationapplication: + return IntegrationApplication.objects.none() + queryset = integrationapplication.get_accounts() + return queryset + + @staticmethod + def filter_latest(queryset, name, value): + if not value: + return queryset + + date = timezone.now() - timezone.timedelta(days=7) + kwargs = {} + + if name == "latest_discovery": + kwargs.update({"date_created__gte": date, "source": "collected"}) + elif name == "latest_accessed": + kwargs.update({"date_last_login__gte": date}) + elif name == "latest_updated": + kwargs.update({"date_updated__gte": date}) + elif name == "latest_secret_changed": + kwargs.update({"date_change_secret__gt": date}) + + if name == "latest_secret_change_failed": + queryset = queryset.filter(date_change_secret__gt=date).exclude( + change_secret_status="ok" + ) + + if kwargs: + queryset = queryset.filter(date_last_login__gte=date) + return queryset + @staticmethod def filter_nodes(queryset, name, value): nodes = Node.objects.filter(id=value) @@ -40,19 +120,22 @@ class AccountFilterSet(BaseFilterSet): node_qs = Node.objects.none() for node in nodes: node_qs |= node.get_all_children(with_self=True) - node_ids = list(node_qs.values_list('id', flat=True)) + node_ids = list(node_qs.values_list("id", flat=True)) queryset = queryset.filter(asset__nodes__in=node_ids) return queryset class Meta: model = Account - fields = ['id', 'asset', 'source_id', 'secret_type'] + fields = ["id", "asset", "source_id", "secret_type", "category", "type"] class GatheredAccountFilterSet(BaseFilterSet): - node_id = drf_filters.CharFilter(method='filter_nodes') - asset_id = drf_filters.CharFilter(field_name='asset_id', lookup_expr='exact') - asset_name = drf_filters.CharFilter(field_name='asset__name', lookup_expr='icontains') + node_id = drf_filters.CharFilter(method="filter_nodes") + asset_id = drf_filters.CharFilter(field_name="asset_id", lookup_expr="exact") + asset_name = drf_filters.CharFilter( + field_name="asset__name", lookup_expr="icontains" + ) + status = drf_filters.CharFilter(field_name="status", lookup_expr="exact") @staticmethod def filter_nodes(queryset, name, value): @@ -60,14 +143,38 @@ class GatheredAccountFilterSet(BaseFilterSet): class Meta: model = GatheredAccount - fields = ['id', 'username'] + fields = ["id", "username"] -class ChangeSecretRecordFilterSet(BaseFilterSet): - asset_name = drf_filters.CharFilter(field_name='asset__name', lookup_expr='icontains') - account_username = drf_filters.CharFilter(field_name='account__username', lookup_expr='icontains') - execution_id = drf_filters.CharFilter(field_name='execution_id', lookup_expr='exact') +class SecretRecordMixin: + asset_name = drf_filters.CharFilter( + field_name="asset__name", lookup_expr="icontains" + ) + account_username = drf_filters.CharFilter( + field_name="account__username", lookup_expr="icontains" + ) + execution_id = drf_filters.CharFilter( + field_name="execution_id", lookup_expr="exact" + ) + days = drf_filters.NumberFilter(method="filter_days") + @staticmethod + def filter_days(queryset, name, value): + value = int(value) + + dt = local_zero_hour() + if value != 1: + dt = local_now() - timezone.timedelta(days=value) + return queryset.filter(date_finished__gte=dt) + + +class ChangeSecretRecordFilterSet(SecretRecordMixin, BaseFilterSet): class Meta: model = ChangeSecretRecord - fields = ['id', 'status', 'asset_id', 'execution'] + fields = ["id", "status", "asset_id", "execution"] + + +class PushAccountRecordFilterSet(SecretRecordMixin, BaseFilterSet): + class Meta: + model = PushSecretRecord + fields = ["id", "status", "asset_id", "execution"] diff --git a/apps/accounts/migrations/0001_initial.py b/apps/accounts/migrations/0001_initial.py index dbecc94d0..817b3246c 100644 --- a/apps/accounts/migrations/0001_initial.py +++ b/apps/accounts/migrations/0001_initial.py @@ -94,7 +94,7 @@ class Migration(migrations.Migration): ('snapshot', models.JSONField(blank=True, default=dict, encoder=common.db.encoder.ModelJSONFieldEncoder, null=True, verbose_name='Account backup snapshot')), - ('trigger', models.CharField(choices=[('manual', 'Manual trigger'), ('timing', 'Timing trigger')], + ('trigger', models.CharField(choices=[('manual', 'Manual'), ('timing', 'Timing')], default='manual', max_length=128, verbose_name='Trigger mode')), ('reason', models.CharField(blank=True, max_length=1024, null=True, verbose_name='Reason')), ('is_success', models.BooleanField(default=False, verbose_name='Is success')), @@ -168,7 +168,7 @@ class Migration(migrations.Migration): ('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')), - ('present', models.BooleanField(default=True, verbose_name='Present')), + ('present', models.BooleanField(default=True, verbose_name='Remote present')), ('date_last_login', models.DateTimeField(null=True, verbose_name='Date login')), ('username', models.CharField(blank=True, db_index=True, max_length=32, verbose_name='Username')), ('address_last_login', models.CharField(default='', max_length=39, verbose_name='Address login')), diff --git a/apps/accounts/migrations/0002_auto_20220616_0021.py b/apps/accounts/migrations/0002_auto_20220616_0021.py index 8fe829dd6..c5bfb0d99 100644 --- a/apps/accounts/migrations/0002_auto_20220616_0021.py +++ b/apps/accounts/migrations/0002_auto_20220616_0021.py @@ -50,7 +50,15 @@ class Migration(migrations.Migration): ('secret', common.db.fields.EncryptTextField(blank=True, null=True, verbose_name='Secret')), ('secret_strategy', models.CharField(choices=[('specific', 'Specific secret'), ('random', 'Random generate')], default='specific', max_length=16, verbose_name='Secret strategy')), ('password_rules', models.JSONField(default=dict, verbose_name='Password rules')), - ('ssh_key_change_strategy', models.CharField(choices=[('set_jms', 'Replace (Replace only keys pushed by JumpServer) '), ('set', 'Empty and append SSH KEY'), ('add', 'Append SSH KEY')], default='set_jms', max_length=16, verbose_name='SSH key change strategy')), + ('ssh_key_change_strategy', models.CharField( + choices=[ + ("set_jms", "Replace (Replace only keys pushed by JumpServer) "), + ("set", "Empty and append SSH KEY"), + ], + default="set_jms", + max_length=16, + verbose_name="SSH key change strategy", + )), ], options={ 'verbose_name': 'Change secret automation', @@ -76,7 +84,15 @@ class Migration(migrations.Migration): ('secret', common.db.fields.EncryptTextField(blank=True, null=True, verbose_name='Secret')), ('secret_strategy', models.CharField(choices=[('specific', 'Specific secret'), ('random', 'Random generate')], default='specific', max_length=16, verbose_name='Secret strategy')), ('password_rules', models.JSONField(default=dict, verbose_name='Password rules')), - ('ssh_key_change_strategy', models.CharField(choices=[('set_jms', 'Replace (Replace only keys pushed by JumpServer) '), ('set', 'Empty and append SSH KEY'), ('add', 'Append SSH KEY')], default='set_jms', max_length=16, verbose_name='SSH key change strategy')), + ('ssh_key_change_strategy', models.CharField( + choices=[ + ("set_jms", "Replace (Replace only keys pushed by JumpServer) "), + ("set", "Empty and append SSH KEY"), + ], + default="set_jms", + max_length=16, + verbose_name="SSH key change strategy", + )), ('triggers', models.JSONField(default=list, max_length=16, verbose_name='Triggers')), ('username', models.CharField(max_length=128, verbose_name='Username')), ('action', models.CharField(max_length=16, verbose_name='Action')), diff --git a/apps/accounts/migrations/0005_account_secret_reset.py b/apps/accounts/migrations/0005_account_secret_reset.py new file mode 100644 index 000000000..e58d818c3 --- /dev/null +++ b/apps/accounts/migrations/0005_account_secret_reset.py @@ -0,0 +1,244 @@ +# Generated by Django 4.1.13 on 2024-11-01 10:24 + +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ("assets", "0006_baseautomation_start_time"), + ("accounts", "0004_alter_changesecretrecord_account_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="AccountCheckAutomation", + fields=[ + ( + "baseautomation_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="assets.baseautomation", + ), + ), + ], + options={ + "verbose_name": "Gather account automation", + }, + bases=("accounts.accountbaseautomation",), + ), + migrations.AddField( + model_name="account", + name="change_secret_status", + field=models.CharField( + blank=True, + max_length=16, + null=True, + verbose_name="Change secret status", + ), + ), + migrations.AddField( + model_name="account", + name="date_change_secret", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Date change secret" + ), + ), + migrations.AddField( + model_name="account", + name="date_last_login", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Date last access" + ), + ), + migrations.AddField( + model_name="account", + name="login_by", + field=models.CharField( + blank=True, max_length=128, null=True, verbose_name="Access by" + ), + ), + migrations.AddField( + model_name="account", + name="secret_reset", + field=models.BooleanField(default=True, verbose_name="Secret reset"), + ), + migrations.AddField( + model_name="accountbackupautomation", + name="start_time", + field=models.DateTimeField( + blank=True, + help_text="Datetime when the schedule should begin triggering the task to run", + null=True, + verbose_name="Start Datetime", + ), + ), + migrations.AddField( + model_name="gatheredaccount", + name="authorized_keys", + field=models.TextField( + blank=True, default="", verbose_name="Authorized keys" + ), + ), + migrations.AddField( + model_name="gatheredaccount", + name="groups", + field=models.TextField(blank=True, default="", verbose_name="Groups"), + ), + migrations.AddField( + model_name="gatheredaccount", + name="remote_present", + field=models.BooleanField(default=True, verbose_name="Remote present"), + ), + migrations.AddField( + model_name="gatheredaccount", + name="status", + field=models.CharField( + blank=True, + choices=[("confirmed", "Confirmed"), ("ignored", "Ignored"), ("pending", "Pending")], + default="", + max_length=32, + verbose_name="Status", + ), + ), + migrations.AddField( + model_name="gatheredaccount", + name="sudoers", + field=models.TextField(blank=True, default="", verbose_name="Sudoers"), + ), + migrations.AlterField( + model_name="account", + name="connectivity", + field=models.CharField( + choices=[ + ("-", "Unknown"), + ("na", "N/A"), + ("ok", "OK"), + ("err", "Error"), + ], + default="-", + max_length=16, + verbose_name="Connectivity", + ), + ), + migrations.AlterField( + model_name="gatheredaccount", + name="present", + field=models.BooleanField(default=False, verbose_name="Present"), + ), + migrations.CreateModel( + name="GatheredAccountDiff", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("diff", models.TextField(default="", verbose_name="Diff")), + ( + "item", + models.CharField(default="", max_length=32, verbose_name="Item"), + ), + ( + "date_created", + models.DateTimeField( + auto_now_add=True, verbose_name="Date created" + ), + ), + ( + "account", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="accounts.gatheredaccount", + verbose_name="Gathered account", + ), + ), + ], + ), + migrations.CreateModel( + name="AccountRisk", + 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", + ), + ), + ( + "risk", + models.CharField( + choices=[ + ("zombie", "Zombie"), + ("ghost", "Ghost"), + ("weak_password", "Weak password"), + ("long_time_no_change", "Long time no change"), + ], + max_length=128, + verbose_name="Risk", + ), + ), + ( + "confirmed", + models.BooleanField(default=False, verbose_name="Confirmed"), + ), + ( + "account", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="risks", + to="accounts.account", + verbose_name="Account", + ), + ), + ], + options={ + "verbose_name": "Account risk", + }, + ), + ] diff --git a/apps/accounts/migrations/0006_remove_accountrisk_account_accountrisk_asset_and_more.py b/apps/accounts/migrations/0006_remove_accountrisk_account_accountrisk_asset_and_more.py new file mode 100644 index 000000000..6cccf9b8e --- /dev/null +++ b/apps/accounts/migrations/0006_remove_accountrisk_account_accountrisk_asset_and_more.py @@ -0,0 +1,58 @@ +# Generated by Django 4.1.13 on 2024-11-04 06:37 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("assets", "0006_baseautomation_start_time"), + ("accounts", "0005_account_secret_reset"), + ] + + operations = [ + migrations.RemoveField( + model_name="accountrisk", + name="account", + ), + migrations.AddField( + model_name="accountrisk", + name="asset", + field=models.ForeignKey( + default=None, + on_delete=django.db.models.deletion.CASCADE, + related_name="risks", + to="assets.asset", + verbose_name="Asset", + ), + preserve_default=False, + ), + migrations.AddField( + model_name="accountrisk", + name="username", + field=models.CharField(default="", max_length=32, verbose_name="Username"), + preserve_default=False, + ), + migrations.AlterField( + model_name="accountrisk", + name="risk", + field=models.CharField( + choices=[ + ("zombie", "Long time no login"), + ("ghost", "Not managed"), + ("long_time_no_change", "Long time no change"), + ("weak_password", "Weak password"), + ("login_bypass", "Login bypass"), + ("group_change", "Group change"), + ("account_delete", "Account delete"), + ("password_expired", "Password expired"), + ("no_admin_account", "No admin account"), + ("password_error", "Password error"), + ("other", "Other"), + ], + max_length=128, + verbose_name="Risk", + ), + ), + ] diff --git a/apps/accounts/migrations/0007_alter_accountrisk_risk.py b/apps/accounts/migrations/0007_alter_accountrisk_risk.py new file mode 100644 index 000000000..e5bc75ae4 --- /dev/null +++ b/apps/accounts/migrations/0007_alter_accountrisk_risk.py @@ -0,0 +1,34 @@ +# Generated by Django 4.1.13 on 2024-11-04 06:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("accounts", "0006_remove_accountrisk_account_accountrisk_asset_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="accountrisk", + name="risk", + field=models.CharField( + choices=[ + ('zombie', 'Long time no login'), + ('ghost', 'Not managed'), + ('long_time_password', 'Long time no change'), + ('weak_password', 'Weak password'), + ('password_error', 'Password error'), + ('password_expired', 'Password expired'), + ('group_changed', 'Group change'), + ('sudo_changed', 'Sudo changed'), + ('account_deleted', 'Account delete'), + ('no_admin_account', 'No admin account'), + ('others', 'Others') + ], + max_length=128, + verbose_name="Risk", + ), + ), + ] diff --git a/apps/accounts/migrations/0008_remove_accountrisk_confirmed_accountrisk_status_and_more.py b/apps/accounts/migrations/0008_remove_accountrisk_confirmed_accountrisk_status_and_more.py new file mode 100644 index 000000000..1c1a31219 --- /dev/null +++ b/apps/accounts/migrations/0008_remove_accountrisk_confirmed_accountrisk_status_and_more.py @@ -0,0 +1,60 @@ +# Generated by Django 4.1.13 on 2024-11-06 08:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("accounts", "0007_alter_accountrisk_risk"), + ] + + operations = [ + migrations.RemoveField( + model_name="accountrisk", + name="confirmed", + ), + migrations.AddField( + model_name="accountrisk", + name="status", + field=models.CharField( + blank=True, + choices=[("confirmed", "Confirmed"), ("ignored", "Ignored"), ("pending", "Pending")], + default="", + max_length=32, + verbose_name="Status", + ), + ), + migrations.AlterField( + model_name="accountrisk", + name="risk", + field=models.CharField( + choices=[ + ("zombie", "Long time no login"), + ("ghost", "Not managed"), + ("long_time_password", "Long time no change"), + ("weak_password", "Weak password"), + ("password_error", "Password error"), + ("password_expired", "Password expired"), + ("group_changed", "Group change"), + ("sudo_changed", "Sudo changed"), + ("authorized_keys_changed", "Authorized keys changed"), + ("account_deleted", "Account delete"), + ("no_admin_account", "No admin account"), + ("others", "Others"), + ], + max_length=128, + verbose_name="Risk", + ), + ), + migrations.AddField( + model_name='changesecretautomation', + name='check_conn_after_change', + field=models.BooleanField(default=True, verbose_name='Check connection after change'), + ), + migrations.AddField( + model_name='pushaccountautomation', + name='check_conn_after_change', + field=models.BooleanField(default=True, verbose_name='Check connection after change'), + ), + ] diff --git a/apps/accounts/migrations/0009_alter_accountrisk_comment.py b/apps/accounts/migrations/0009_alter_accountrisk_comment.py new file mode 100644 index 000000000..f2c2fb456 --- /dev/null +++ b/apps/accounts/migrations/0009_alter_accountrisk_comment.py @@ -0,0 +1,58 @@ +# Generated by Django 4.1.13 on 2024-11-08 09:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("assets", "0006_baseautomation_start_time"), + ("accounts", "0008_remove_accountrisk_confirmed_accountrisk_status_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="gatheredaccount", + name="date_change_password", + field=models.DateTimeField(null=True, verbose_name="Date change password"), + ), + migrations.AddField( + model_name="gatheredaccount", + name="date_password_expired", + field=models.DateTimeField(null=True, verbose_name="Date password expired"), + ), + migrations.AlterField( + model_name="accountrisk", + name="comment", + field=models.TextField(default="", verbose_name="Comment"), + ), + migrations.AlterField( + model_name="accountrisk", + name="risk", + field=models.CharField( + choices=[ + ("zombie", "Long time no login"), + ("ghost", "Not managed"), + ("groups_changed", "Groups change"), + ("sudoers_changed", "Sudo changed"), + ("authorized_keys_changed", "Authorized keys changed"), + ("account_deleted", "Account delete"), + ("password_expired", "Password expired"), + ("long_time_password", "Long time no change"), + ("weak_password", "Weak password"), + ("password_error", "Password error"), + ("no_admin_account", "No admin account"), + ("others", "Others"), + ], + max_length=128, + verbose_name="Risk", + ), + ), + migrations.AlterUniqueTogether( + name="accountrisk", + unique_together={("asset", "username", "risk")}, + ), + migrations.DeleteModel( + name="GatheredAccountDiff", + ), + ] diff --git a/apps/accounts/migrations/0010_accountrisk_details_alter_accountrisk_comment.py b/apps/accounts/migrations/0010_accountrisk_details_alter_accountrisk_comment.py new file mode 100644 index 000000000..8674cc5ef --- /dev/null +++ b/apps/accounts/migrations/0010_accountrisk_details_alter_accountrisk_comment.py @@ -0,0 +1,23 @@ +# Generated by Django 4.1.13 on 2024-11-11 05:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("accounts", "0009_alter_accountrisk_comment"), + ] + + operations = [ + migrations.AddField( + model_name="accountrisk", + name="details", + field=models.JSONField(default=list, verbose_name="Details"), + ), + migrations.AlterField( + model_name="accountrisk", + name="comment", + field=models.TextField(blank=True, default="", verbose_name="Comment"), + ), + ] diff --git a/apps/accounts/migrations/0011_rename_date_change_password_gatheredaccount_date_password_change.py b/apps/accounts/migrations/0011_rename_date_change_password_gatheredaccount_date_password_change.py new file mode 100644 index 000000000..c55844b33 --- /dev/null +++ b/apps/accounts/migrations/0011_rename_date_change_password_gatheredaccount_date_password_change.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.13 on 2024-11-12 06:35 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("accounts", "0010_accountrisk_details_alter_accountrisk_comment"), + ] + + operations = [ + migrations.RenameField( + model_name="gatheredaccount", + old_name="date_change_password", + new_name="date_password_change", + ), + ] diff --git a/apps/accounts/migrations/0012_accountcheckengine_accountcheckautomation_engines.py b/apps/accounts/migrations/0012_accountcheckengine_accountcheckautomation_engines.py new file mode 100644 index 000000000..ef1bce43b --- /dev/null +++ b/apps/accounts/migrations/0012_accountcheckengine_accountcheckautomation_engines.py @@ -0,0 +1,121 @@ +# Generated by Django 4.1.13 on 2024-11-14 11:00 + +import uuid + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("assets", "0007_baseautomation_date_last_run_and_more"), + ( + "accounts", + "0011_rename_date_change_password_gatheredaccount_date_password_change", + ), + ] + + operations = [ + migrations.CreateModel( + name="CheckAccountAutomation", + fields=[ + ( + "baseautomation_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="assets.baseautomation", + ), + ), + ], + options={ + "verbose_name": "account check automation", + "permissions": [ + ("view_checkaccountexecution", "Can view check account execution"), + ("add_checkaccountexecution", "Can add check account execution"), + ], + }, + bases=("accounts.accountbaseautomation",), + ), + migrations.CreateModel( + name="CheckAccountEngine", + 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 + ), + ), + ( + "name", + models.CharField(max_length=128, unique=True, verbose_name="Name"), + ), + ( + "slug", + models.SlugField(max_length=128, unique=True, verbose_name="Slug"), + ), + ( + "is_active", + models.BooleanField(default=True, verbose_name="Is active"), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.AddField( + model_name="accountbackupautomation", + name="date_last_run", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Date last run" + ), + ), + migrations.AlterField( + model_name="accountbackupautomation", + name="crontab", + field=models.CharField( + blank=True, default="", max_length=128, verbose_name="Crontab" + ), + ), + migrations.DeleteModel( + name="AccountCheckAutomation", + ), + migrations.AddField( + model_name="checkaccountautomation", + name="engines", + field=models.ManyToManyField( + related_name="check_automations", + to="accounts.checkaccountengine", + verbose_name="Engines", + ), + ), + ] diff --git a/apps/accounts/migrations/0013_checkaccountautomation_recipients.py b/apps/accounts/migrations/0013_checkaccountautomation_recipients.py new file mode 100644 index 000000000..4197379b2 --- /dev/null +++ b/apps/accounts/migrations/0013_checkaccountautomation_recipients.py @@ -0,0 +1,22 @@ +# Generated by Django 4.1.13 on 2024-11-15 03:00 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("accounts", "0012_accountcheckengine_accountcheckautomation_engines"), + ] + + operations = [ + migrations.AddField( + model_name="checkaccountautomation", + name="recipients", + field=models.ManyToManyField( + blank=True, to=settings.AUTH_USER_MODEL, verbose_name="Recipient" + ), + ), + ] diff --git a/apps/accounts/migrations/0014_gatheraccountsautomation_check_risk.py b/apps/accounts/migrations/0014_gatheraccountsautomation_check_risk.py new file mode 100644 index 000000000..79c59ea6b --- /dev/null +++ b/apps/accounts/migrations/0014_gatheraccountsautomation_check_risk.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.13 on 2024-11-18 03:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("accounts", "0013_checkaccountautomation_recipients"), + ] + + operations = [ + migrations.AddField( + model_name="gatheraccountsautomation", + name="check_risk", + field=models.BooleanField(default=True, verbose_name="Check risk"), + ), + ] diff --git a/apps/accounts/migrations/0015_alter_accountrisk_risk.py b/apps/accounts/migrations/0015_alter_accountrisk_risk.py new file mode 100644 index 000000000..dbe87b13a --- /dev/null +++ b/apps/accounts/migrations/0015_alter_accountrisk_risk.py @@ -0,0 +1,35 @@ +# Generated by Django 4.1.13 on 2024-11-26 08:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("accounts", "0014_gatheraccountsautomation_check_risk"), + ] + + operations = [ + migrations.AlterField( + model_name="accountrisk", + name="risk", + field=models.CharField( + choices=[ + ("long_time_no_login", "Long time no login"), + ("new_found", "New found"), + ("groups_changed", "Groups change"), + ("sudoers_changed", "Sudo changed"), + ("authorized_keys_changed", "Authorized keys changed"), + ("account_deleted", "Account delete"), + ("password_expired", "Password expired"), + ("long_time_password", "Long time no change"), + ("weak_password", "Weak password"), + ("password_error", "Password error"), + ("no_admin_account", "No admin account"), + ("others", "Others"), + ], + max_length=128, + verbose_name="Risk", + ), + ), + ] diff --git a/apps/accounts/migrations/0016_alter_accountrisk_status_and_more.py b/apps/accounts/migrations/0016_alter_accountrisk_status_and_more.py new file mode 100644 index 000000000..6585180ae --- /dev/null +++ b/apps/accounts/migrations/0016_alter_accountrisk_status_and_more.py @@ -0,0 +1,35 @@ +# Generated by Django 4.1.13 on 2024-11-27 11:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("accounts", "0015_alter_accountrisk_risk"), + ] + + operations = [ + migrations.AlterField( + model_name="accountrisk", + name="status", + field=models.CharField( + blank=True, + choices=[("0", "Pending"), ("1", "Confirmed"), ("2", "Ignored")], + default="0", + max_length=32, + verbose_name="Status", + ), + ), + migrations.AlterField( + model_name="gatheredaccount", + name="status", + field=models.CharField( + blank=True, + choices=[("0", "Pending"), ("1", "Confirmed"), ("2", "Ignored")], + default="0", + max_length=32, + verbose_name="Status", + ), + ), + ] diff --git a/apps/accounts/migrations/0017_serviceintegration.py b/apps/accounts/migrations/0017_serviceintegration.py new file mode 100644 index 000000000..7b5ccdca0 --- /dev/null +++ b/apps/accounts/migrations/0017_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', '0016_alter_accountrisk_status_and_more'), + ] + + 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': 'Application integration', + 'unique_together': {('name', 'org_id')}, + }, + ), + ] diff --git a/apps/accounts/migrations/0018_changesecretrecord_ignore_fail_and_more.py b/apps/accounts/migrations/0018_changesecretrecord_ignore_fail_and_more.py new file mode 100644 index 000000000..a2805de64 --- /dev/null +++ b/apps/accounts/migrations/0018_changesecretrecord_ignore_fail_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.1.13 on 2024-12-02 03:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0017_serviceintegration'), + ] + + operations = [ + migrations.AddField( + model_name='changesecretrecord', + name='ignore_fail', + field=models.BooleanField(default=False, verbose_name='Ignore fail'), + ), + migrations.AlterField( + model_name='changesecretrecord', + name='date_finished', + field=models.DateTimeField(blank=True, db_index=True, null=True, verbose_name='Date finished'), + ), + ] diff --git a/apps/accounts/migrations/0019_backupaccountautomation_and_more.py b/apps/accounts/migrations/0019_backupaccountautomation_and_more.py new file mode 100644 index 000000000..47dbab646 --- /dev/null +++ b/apps/accounts/migrations/0019_backupaccountautomation_and_more.py @@ -0,0 +1,133 @@ +# Generated by Django 4.1.13 on 2024-12-03 09:23 +from datetime import timedelta as dt_timedelta + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + +import common.db.fields + + +def migrate_account_backup(apps, schema_editor): + old_backup_model = apps.get_model('accounts', 'AccountBackupAutomation') + account_backup_model = apps.get_model('accounts', 'BackupAccountAutomation') + backup_id_old_new_map = {} + for backup in old_backup_model.objects.all(): + data = { + 'comment': backup.comment, + 'created_by': backup.created_by, + 'updated_by': backup.updated_by, + 'date_created': backup.date_created, + 'date_updated': backup.date_updated, + 'name': backup.name, + 'interval': backup.interval, + 'crontab': backup.crontab, + 'is_periodic': backup.is_periodic, + 'start_time': backup.start_time, + 'date_last_run': backup.date_last_run, + 'org_id': backup.org_id, + 'type': 'backup_account', + 'types': backup.types, + 'backup_type': backup.backup_type, + 'is_password_divided_by_email': backup.is_password_divided_by_email, + 'is_password_divided_by_obj_storage': backup.is_password_divided_by_obj_storage, + 'zip_encrypt_password': backup.zip_encrypt_password + } + obj = account_backup_model.objects.create(**data) + backup_id_old_new_map[str(backup.id)] = str(obj.id) + obj.recipients_part_one.set(backup.recipients_part_one.all()) + obj.recipients_part_two.set(backup.recipients_part_two.all()) + obj.obj_recipients_part_one.set(backup.obj_recipients_part_one.all()) + obj.obj_recipients_part_two.set(backup.obj_recipients_part_two.all()) + + old_execution_model = apps.get_model('accounts', 'AccountBackupExecution') + backup_execution_model = apps.get_model('accounts', 'AutomationExecution') + + for execution in old_execution_model.objects.all(): + automation_id = backup_id_old_new_map.get(str(execution.plan_id)) + if not automation_id: + continue + data = { + 'automation_id': automation_id, + 'date_start': execution.date_start, + 'duration': int(execution.timedelta), + 'date_finished': execution.date_start + dt_timedelta(seconds=int(execution.timedelta)), + 'snapshot': execution.snapshot, + 'trigger': execution.trigger, + 'status': 'error' if execution.reason == '-' else 'success', + 'org_id': execution.org_id + } + backup_execution_model.objects.create(**data) + + +class Migration(migrations.Migration): + dependencies = [ + ('assets', '0010_alter_automationexecution_duration'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('terminal', '0003_auto_20171230_0308'), + ('accounts', '0018_changesecretrecord_ignore_fail_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='BackupAccountAutomation', + fields=[ + ('baseautomation_ptr', + models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, + primary_key=True, serialize=False, to='assets.baseautomation')), + ('types', models.JSONField(default=list)), + ('backup_type', + models.CharField(choices=[('email', 'Email'), ('object_storage', 'SFTP')], default='email', + max_length=128, verbose_name='Backup type')), + ('is_password_divided_by_email', models.BooleanField(default=True, verbose_name='Password divided')), + ('is_password_divided_by_obj_storage', + models.BooleanField(default=True, verbose_name='Password divided')), + ('zip_encrypt_password', common.db.fields.EncryptCharField(blank=True, max_length=4096, null=True, + verbose_name='Zip encrypt password')), + ('obj_recipients_part_one', + models.ManyToManyField(blank=True, related_name='obj_recipient_part_one_plans', + to='terminal.replaystorage', verbose_name='Object storage recipient part one')), + ('obj_recipients_part_two', + models.ManyToManyField(blank=True, related_name='obj_recipient_part_two_plans', + to='terminal.replaystorage', verbose_name='Object storage recipient part two')), + ('recipients_part_one', models.ManyToManyField(blank=True, related_name='recipient_part_one_plans', + to=settings.AUTH_USER_MODEL, + verbose_name='Recipient part one')), + ('recipients_part_two', models.ManyToManyField(blank=True, related_name='recipient_part_two_plans', + to=settings.AUTH_USER_MODEL, + verbose_name='Recipient part two')), + ], + options={ + 'verbose_name': 'Account backup plan', + }, + bases=('accounts.accountbaseautomation',), + ), + migrations.RunPython(migrate_account_backup), + migrations.RemoveField( + model_name='accountbackupexecution', + name='plan', + ), + migrations.DeleteModel( + name='AccountBackupAutomation', + ), + migrations.DeleteModel( + name='AccountBackupExecution', + ), + migrations.RemoveField( + model_name='gatheredaccount', + name='authorized_keys', + ), + migrations.RemoveField( + model_name='gatheredaccount', + name='groups', + ), + migrations.RemoveField( + model_name='gatheredaccount', + name='sudoers', + ), + migrations.AddField( + model_name='gatheredaccount', + name='detail', + field=models.JSONField(blank=True, default=dict, verbose_name='Detail'), + ), + ] diff --git a/apps/accounts/migrations/0020_integrationapplication_delete_serviceintegration_and_more.py b/apps/accounts/migrations/0020_integrationapplication_delete_serviceintegration_and_more.py new file mode 100644 index 000000000..cf07c9c84 --- /dev/null +++ b/apps/accounts/migrations/0020_integrationapplication_delete_serviceintegration_and_more.py @@ -0,0 +1,134 @@ +# Generated by Django 4.1.13 on 2024-12-04 10:49 + +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", "0019_backupaccountautomation_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="IntegrationApplication", + 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", + private_storage.fields.PrivateImageField( + max_length=128, + storage=private_storage.storage.files.PrivateFileSystemStorage(), + upload_to="integration-apps", + 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": "Integration App", + "unique_together": {("name", "org_id")}, + }, + ), + migrations.DeleteModel( + name="ServiceIntegration", + ), + migrations.AlterModelOptions( + name="automationexecution", + options={ + "permissions": [ + ("view_changesecretexecution", "Can view change secret execution"), + ("add_changesecretexecution", "Can add change secret execution"), + ( + "view_gatheraccountsexecution", + "Can view gather accounts execution", + ), + ( + "add_gatheraccountsexecution", + "Can add gather accounts execution", + ), + ("view_pushaccountexecution", "Can view push account execution"), + ("add_pushaccountexecution", "Can add push account execution"), + ( + "view_backupaccountexecution", + "Can view backup account execution", + ), + ("add_backupaccountexecution", "Can add backup account execution"), + ], + "verbose_name": "Automation execution", + "verbose_name_plural": "Automation executions", + }, + ), + ] diff --git a/apps/accounts/migrations/0021_remove_pushaccountautomation_action_and_more.py b/apps/accounts/migrations/0021_remove_pushaccountautomation_action_and_more.py new file mode 100644 index 000000000..610f031f5 --- /dev/null +++ b/apps/accounts/migrations/0021_remove_pushaccountautomation_action_and_more.py @@ -0,0 +1,25 @@ +# Generated by Django 4.1.13 on 2024-12-05 08:39 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0020_integrationapplication_delete_serviceintegration_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='pushaccountautomation', + name='action', + ), + migrations.RemoveField( + model_name='pushaccountautomation', + name='triggers', + ), + migrations.RemoveField( + model_name='pushaccountautomation', + name='username', + ), + ] diff --git a/apps/accounts/migrations/0022_alter_changesecretrecord_options_and_more.py b/apps/accounts/migrations/0022_alter_changesecretrecord_options_and_more.py new file mode 100644 index 000000000..21bbc2c08 --- /dev/null +++ b/apps/accounts/migrations/0022_alter_changesecretrecord_options_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 4.1.13 on 2024-12-09 03:15 + +from django.db import migrations +import private_storage.fields +import private_storage.storage.files + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0021_remove_pushaccountautomation_action_and_more'), + ] + + operations = [ + migrations.AlterModelOptions( + name='changesecretrecord', + options={'permissions': [('view_pushsecretrecord', 'Can view change secret execution'), ('add_pushsecretexecution', 'Can add change secret execution')], 'verbose_name': 'Change secret record'}, + ), + migrations.AlterField( + model_name='integrationapplication', + name='logo', + field=private_storage.fields.PrivateImageField(max_length=128, storage=private_storage.storage.files.PrivateFileSystemStorage(), upload_to='images', verbose_name='Logo'), + ), + ] diff --git a/apps/accounts/migrations/0023_alter_changesecretrecord_options.py b/apps/accounts/migrations/0023_alter_changesecretrecord_options.py new file mode 100644 index 000000000..1fb1addbe --- /dev/null +++ b/apps/accounts/migrations/0023_alter_changesecretrecord_options.py @@ -0,0 +1,17 @@ +# Generated by Django 4.1.13 on 2024-12-10 11:29 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0022_alter_changesecretrecord_options_and_more'), + ] + + operations = [ + migrations.AlterModelOptions( + name='changesecretrecord', + options={'verbose_name': 'Change secret record'}, + ), + ] diff --git a/apps/accounts/migrations/0024_remove_changesecretrecord_date_started_and_more.py b/apps/accounts/migrations/0024_remove_changesecretrecord_date_started_and_more.py new file mode 100644 index 000000000..e7b1a928b --- /dev/null +++ b/apps/accounts/migrations/0024_remove_changesecretrecord_date_started_and_more.py @@ -0,0 +1,42 @@ +# Generated by Django 4.1.13 on 2024-12-24 05:27 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('assets', '0011_auto_20241204_1516'), + ('accounts', '0023_alter_changesecretrecord_options'), + ] + + operations = [ + migrations.RemoveField( + model_name='changesecretrecord', + name='date_started', + ), + migrations.AlterField( + model_name='changesecretrecord', + name='account', + field=models.ForeignKey( + null=True, on_delete=django.db.models.deletion.SET_NULL, + related_name='change_secret_records', to='accounts.account' + ), + ), + migrations.AlterField( + model_name='changesecretrecord', + name='asset', + field=models.ForeignKey( + null=True, on_delete=django.db.models.deletion.SET_NULL, + related_name='asset_change_secret_records', to='assets.asset' + ), + ), + migrations.AlterField( + model_name='changesecretrecord', + name='execution', + field=models.ForeignKey( + null=True, on_delete=django.db.models.deletion.SET_NULL, + related_name='execution_change_secret_records', to='accounts.automationexecution' + ), + ), + ] diff --git a/apps/accounts/migrations/0025_alter_accountrisk_risk_and_more.py b/apps/accounts/migrations/0025_alter_accountrisk_risk_and_more.py new file mode 100644 index 000000000..e38a566e5 --- /dev/null +++ b/apps/accounts/migrations/0025_alter_accountrisk_risk_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.1.13 on 2025-01-09 11:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0024_remove_changesecretrecord_date_started_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='accountrisk', + name='risk', + field=models.CharField(choices=[('long_time_no_login', 'Long time no login'), ('new_found', 'New found'), ('groups_changed', 'Groups change'), ('sudoers_changed', 'Sudo changed'), ('authorized_keys_changed', 'Authorized keys changed'), ('account_deleted', 'Account delete'), ('password_expired', 'Password expired'), ('long_time_password', 'Long time no change'), ('weak_password', 'Weak password'), ('leaked_password', 'Leaked password'), ('repeated_password', 'Repeated password'), ('password_error', 'Password error'), ('no_admin_account', 'No admin account'), ('others', 'Others')], max_length=128, verbose_name='Risk'), + ), + migrations.AlterField( + model_name='gatheredaccount', + name='address_last_login', + field=models.CharField(default='', max_length=39, null=True, verbose_name='Address login'), + ), + ] diff --git a/apps/accounts/migrations/0026_accountrisk_account.py b/apps/accounts/migrations/0026_accountrisk_account.py new file mode 100644 index 000000000..da4c79b0c --- /dev/null +++ b/apps/accounts/migrations/0026_accountrisk_account.py @@ -0,0 +1,25 @@ +# Generated by Django 4.1.13 on 2025-01-13 03:13 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("accounts", "0025_alter_accountrisk_risk_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="accountrisk", + name="account", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="risks", + to="accounts.account", + verbose_name="Account", + ), + ), + ] diff --git a/apps/accounts/migrations/0027_accountrisk_gathered_account.py b/apps/accounts/migrations/0027_accountrisk_gathered_account.py new file mode 100644 index 000000000..12077b8bd --- /dev/null +++ b/apps/accounts/migrations/0027_accountrisk_gathered_account.py @@ -0,0 +1,24 @@ +# Generated by Django 4.1.13 on 2025-01-13 07:36 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("accounts", "0026_accountrisk_account"), + ] + + operations = [ + migrations.AddField( + model_name="accountrisk", + name="gathered_account", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="risks", + to="accounts.gatheredaccount", + ), + ), + ] diff --git a/apps/accounts/migrations/0028_remove_checkaccountengine_is_active_and_more.py b/apps/accounts/migrations/0028_remove_checkaccountengine_is_active_and_more.py new file mode 100644 index 000000000..818f1557b --- /dev/null +++ b/apps/accounts/migrations/0028_remove_checkaccountengine_is_active_and_more.py @@ -0,0 +1,26 @@ +# Generated by Django 4.1.13 on 2025-01-21 08:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0027_accountrisk_gathered_account'), + ] + + operations = [ + migrations.RemoveField( + model_name='checkaccountengine', + name='is_active', + ), + migrations.RemoveField( + model_name='checkaccountautomation', + name='engines', + ), + migrations.AddField( + model_name='checkaccountautomation', + name='engines', + field=models.JSONField(default=list, verbose_name='Engines'), + ), + ] diff --git a/apps/accounts/migrations/0029_alter_changesecretrecord_account_and_more.py b/apps/accounts/migrations/0029_alter_changesecretrecord_account_and_more.py new file mode 100644 index 000000000..09b0e26e9 --- /dev/null +++ b/apps/accounts/migrations/0029_alter_changesecretrecord_account_and_more.py @@ -0,0 +1,51 @@ +# Generated by Django 4.1.13 on 2025-01-23 07:22 + +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('assets', '0011_auto_20241204_1516'), + ('accounts', '0028_remove_checkaccountengine_is_active_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='changesecretrecord', + name='account', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)ss', to='accounts.account'), + ), + migrations.AlterField( + model_name='changesecretrecord', + name='asset', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='asset_%(class)ss', to='assets.asset'), + ), + migrations.AlterField( + model_name='changesecretrecord', + name='execution', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='execution_%(class)ss', to='accounts.automationexecution'), + ), + migrations.CreateModel( + name='PushSecretRecord', + 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)), + ('date_finished', models.DateTimeField(blank=True, db_index=True, null=True, verbose_name='Date finished')), + ('status', models.CharField(default='pending', max_length=16, verbose_name='Status')), + ('error', models.TextField(blank=True, null=True, verbose_name='Error')), + ('account', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)ss', to='accounts.account')), + ('asset', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='asset_%(class)ss', to='assets.asset')), + ('execution', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='execution_%(class)ss', to='accounts.automationexecution')), + ], + options={ + 'verbose_name': 'Push secret record', + }, + ), + ] diff --git a/apps/accounts/models/__init__.py b/apps/accounts/models/__init__.py index 5ec98bb68..5c8ce4320 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 .application import * # noqa diff --git a/apps/accounts/models/account.py b/apps/accounts/models/account.py index 38d37b3ad..1f363b678 100644 --- a/apps/accounts/models/account.py +++ b/apps/accounts/models/account.py @@ -3,17 +3,20 @@ from django.utils.translation import gettext_lazy as _ from simple_history.models import HistoricalRecords from assets.models.base import AbsConnectivity -from common.utils import lazyproperty +from common.utils import lazyproperty, get_logger from labels.mixins import LabeledMixin from .base import BaseAccount from .mixins import VaultModelMixin from ..const import Source +logger = get_logger(__file__) + __all__ = ['Account', 'AccountHistoricalRecords'] class AccountHistoricalRecords(HistoricalRecords): def __init__(self, *args, **kwargs): + self.updated_version = None self.included_fields = kwargs.pop('included_fields', None) super().__init__(*args, **kwargs) @@ -22,18 +25,30 @@ class AccountHistoricalRecords(HistoricalRecords): return super().post_save(instance, created, using=using, **kwargs) check_fields = set(self.included_fields) - {'version'} - history_attrs = instance.history.all().values(*check_fields).first() - if history_attrs is None: + + history_account = instance.history.first() + if history_account is None: + self.updated_version = 0 return super().post_save(instance, created, using=using, **kwargs) + history_attrs = {field: getattr(history_account, field) for field in check_fields} + attrs = {field: getattr(instance, field) for field in check_fields} history_attrs = set(history_attrs.items()) attrs = set(attrs.items()) diff = attrs - history_attrs if not diff: return + self.updated_version = history_account.version + 1 + instance.version = self.updated_version return super().post_save(instance, created, using=using, **kwargs) + def create_historical_record(self, instance, history_type, using=None): + super().create_historical_record(instance, history_type, using=using) + # Ignore deletion history_type: - + if self.updated_version is not None and history_type != '-': + instance.save(update_fields=['version']) + def create_history_model(self, model, inherited): if self.included_fields and not self.excluded_fields: self.excluded_fields = [ @@ -55,8 +70,15 @@ class Account(AbsConnectivity, LabeledMixin, BaseAccount): version = models.IntegerField(default=0, verbose_name=_('Version')) history = AccountHistoricalRecords(included_fields=['id', '_secret', 'secret_type', 'version'], verbose_name=_("historical Account")) + secret_reset = models.BooleanField(default=True, verbose_name=_('Secret reset')) source = models.CharField(max_length=30, default=Source.LOCAL, verbose_name=_('Source')) source_id = models.CharField(max_length=128, null=True, blank=True, verbose_name=_('Source ID')) + date_last_login = models.DateTimeField(null=True, blank=True, verbose_name=_('Date last access')) + login_by = models.CharField(max_length=128, null=True, blank=True, verbose_name=_('Access by')) + date_change_secret = models.DateTimeField(null=True, blank=True, verbose_name=_('Date change secret')) + change_secret_status = models.CharField( + max_length=16, null=True, blank=True, verbose_name=_('Change secret status') + ) class Meta: verbose_name = _('Account') diff --git a/apps/accounts/models/application.py b/apps/accounts/models/application.py new file mode 100644 index 000000000..b214e0319 --- /dev/null +++ b/apps/accounts/models/application.py @@ -0,0 +1,68 @@ +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 IntegrationApplication(JMSOrgBaseModel): + is_anonymous = False + + name = models.CharField(max_length=128, unique=False, verbose_name=_('Name')) + logo = PrivateImageField( + upload_to='images', 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 = _('Integration App') + + def get_accounts(self): + qs = Account.objects.all() + query = RelatedManager.get_to_filter_qs(self.accounts.value, Account) + return qs.filter(*query) + + @property + def accounts_amount(self): + return self.get_accounts().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_integrationapplication'] + 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/models/automations/__init__.py b/apps/accounts/models/automations/__init__.py index 682b182b6..13483eec9 100644 --- a/apps/accounts/models/automations/__init__.py +++ b/apps/accounts/models/automations/__init__.py @@ -1,6 +1,7 @@ from .base import * from .backup_account import * from .change_secret import * +from .check_account import * from .gather_account import * from .push_account import * from .verify_account import * diff --git a/apps/accounts/models/automations/backup_account.py b/apps/accounts/models/automations/backup_account.py index 3388a4b0a..7f44cd226 100644 --- a/apps/accounts/models/automations/backup_account.py +++ b/apps/accounts/models/automations/backup_account.py @@ -1,22 +1,17 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- # -import uuid -from celery import current_task from django.db import models -from django.db.models import F from django.utils.translation import gettext_lazy as _ -from accounts.const import AccountBackupType -from common.const.choices import Trigger +from accounts.const import AccountBackupType, AutomationTypes from common.db import fields -from common.db.encoder import ModelJSONFieldEncoder -from common.utils import get_logger, lazyproperty -from ops.mixin import PeriodTaskModelMixin -from orgs.mixins.models import OrgModelMixin, JMSOrgBaseModel, OrgManager +from orgs.mixins.models import OrgManager +from common.utils import get_logger +from .base import AccountBaseAutomation -__all__ = ['AccountBackupAutomation', 'AccountBackupExecution'] +__all__ = ['BackupAccountAutomation'] logger = get_logger(__file__) @@ -25,10 +20,12 @@ class BaseBackupAutomationManager(OrgManager): pass -class AccountBackupAutomation(PeriodTaskModelMixin, JMSOrgBaseModel): +class BackupAccountAutomation(AccountBaseAutomation): types = models.JSONField(default=list) - backup_type = models.CharField(max_length=128, choices=AccountBackupType.choices, - default=AccountBackupType.email.value, verbose_name=_('Backup type')) + backup_type = models.CharField( + max_length=128, choices=AccountBackupType.choices, + default=AccountBackupType.email, verbose_name=_('Backup type') + ) is_password_divided_by_email = models.BooleanField(default=True, verbose_name=_('Password divided')) is_password_divided_by_obj_storage = models.BooleanField(default=True, verbose_name=_('Password divided')) recipients_part_one = models.ManyToManyField( @@ -57,27 +54,11 @@ class AccountBackupAutomation(PeriodTaskModelMixin, JMSOrgBaseModel): return f'{self.name}({self.org_id})' class Meta: - ordering = ['name'] - unique_together = [('name', 'org_id')] verbose_name = _('Account backup plan') - def get_register_task(self): - from ...tasks import execute_account_backup_task - name = "account_backup_plan_period_{}".format(str(self.id)[:8]) - task = execute_account_backup_task.name - args = (str(self.id), Trigger.timing) - kwargs = {} - return name, task, args, kwargs - def to_attr_json(self): - return { - 'id': self.id, - 'name': self.name, - 'is_periodic': self.is_periodic, - 'interval': self.interval, - 'crontab': self.crontab, - 'org_id': self.org_id, - 'created_by': self.created_by, + attr_json = super().to_attr_json() + attr_json.update({ 'types': self.types, 'backup_type': self.backup_type, 'is_password_divided_by_email': self.is_password_divided_by_email, @@ -99,75 +80,9 @@ class AccountBackupAutomation(PeriodTaskModelMixin, JMSOrgBaseModel): str(obj_storage.id): (str(obj_storage.name), str(obj_storage.type)) for obj_storage in self.obj_recipients_part_two.all() }, - } + }) + return attr_json - @property - def executed_amount(self): - return self.execution.count() - - def execute(self, trigger): - try: - hid = current_task.request.id - except AttributeError: - hid = str(uuid.uuid4()) - execution = AccountBackupExecution.objects.create( - id=hid, plan=self, snapshot=self.to_attr_json(), trigger=trigger - ) - return execution.start() - - @lazyproperty - def latest_execution(self): - return self.execution.first() - - -class AccountBackupExecution(OrgModelMixin): - id = models.UUIDField(default=uuid.uuid4, primary_key=True) - date_start = models.DateTimeField( - auto_now_add=True, verbose_name=_('Date start') - ) - timedelta = models.FloatField( - default=0.0, verbose_name=_('Time'), null=True - ) - snapshot = models.JSONField( - encoder=ModelJSONFieldEncoder, default=dict, - blank=True, null=True, verbose_name=_('Account backup snapshot') - ) - trigger = models.CharField( - max_length=128, default=Trigger.manual, choices=Trigger.choices, - verbose_name=_('Trigger mode') - ) - reason = models.CharField( - max_length=1024, blank=True, null=True, verbose_name=_('Reason') - ) - is_success = models.BooleanField(default=False, verbose_name=_('Is success')) - plan = models.ForeignKey( - 'AccountBackupAutomation', related_name='execution', on_delete=models.CASCADE, - verbose_name=_('Account backup plan') - ) - - class Meta: - ordering = ('-date_start',) - verbose_name = _('Account backup execution') - - @property - def types(self): - types = self.snapshot.get('types') - return types - - @lazyproperty - def backup_accounts(self): - from accounts.models import Account - # TODO 可以优化一下查询 在账号上做 category 的缓存 避免数据量大时连表操作 - qs = Account.objects.filter( - asset__platform__type__in=self.types - ).annotate(type=F('asset__platform__type')) - return qs - - @property - def manager_type(self): - return 'backup_account' - - def start(self): - from accounts.automations.endpoint import ExecutionManager - manager = ExecutionManager(execution=self) - return manager.run() + def save(self, *args, **kwargs): + self.type = AutomationTypes.backup_account + super().save(*args, **kwargs) diff --git a/apps/accounts/models/automations/base.py b/apps/accounts/models/automations/base.py index b001e8cc7..19fa891e4 100644 --- a/apps/accounts/models/automations/base.py +++ b/apps/accounts/models/automations/base.py @@ -40,33 +40,49 @@ class AutomationExecution(AssetAutomationExecution): ('view_pushaccountexecution', _('Can view push account execution')), ('add_pushaccountexecution', _('Can add push account execution')), + + ('view_backupaccountexecution', _('Can view backup account execution')), + ('add_backupaccountexecution', _('Can add backup account execution')), ] - def start(self): + @property + def manager(self): from accounts.automations.endpoint import ExecutionManager manager = ExecutionManager(execution=self) - return manager.run() + return manager class ChangeSecretMixin(SecretWithRandomMixin): ssh_key_change_strategy = models.CharField( - choices=SSHKeyStrategy.choices, max_length=16, - default=SSHKeyStrategy.set_jms, verbose_name=_('SSH key change strategy') + choices=SSHKeyStrategy.choices, + max_length=16, + default=SSHKeyStrategy.set_jms, + verbose_name=_('SSH key change strategy') + ) + check_conn_after_change = models.BooleanField( + default=True, + verbose_name=_('Check connection after change') ) get_all_assets: callable # get all assets + accounts: list # account usernames class Meta: abstract = True - def create_nonlocal_accounts(self, usernames, asset): - pass + def gen_nonlocal_accounts(self, usernames, asset): + return [] def get_account_ids(self): + account_objs = [] usernames = self.accounts - accounts = Account.objects.none() - for asset in self.get_all_assets(): - self.create_nonlocal_accounts(usernames, asset) - accounts = accounts | asset.accounts.all() + assets = self.get_all_assets() + for asset in assets: + objs = self.gen_nonlocal_accounts(usernames, asset) + account_objs.extend(objs) + + Account.objects.bulk_create(account_objs) + + accounts = Account.objects.filter(asset__in=assets) account_ids = accounts.filter( username__in=usernames, secret_type=self.secret_type ).values_list('id', flat=True) @@ -81,5 +97,6 @@ class ChangeSecretMixin(SecretWithRandomMixin): 'password_rules': self.password_rules, 'secret_strategy': self.secret_strategy, 'ssh_key_change_strategy': self.ssh_key_change_strategy, + 'check_conn_after_change': self.check_conn_after_change, }) return attr_json diff --git a/apps/accounts/models/automations/change_secret.py b/apps/accounts/models/automations/change_secret.py index 6d1c22715..0c22d2088 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 ( @@ -8,7 +9,7 @@ from common.db import fields from common.db.models import JMSBaseModel from .base import AccountBaseAutomation, ChangeSecretMixin -__all__ = ['ChangeSecretAutomation', 'ChangeSecretRecord', ] +__all__ = ['ChangeSecretAutomation', 'ChangeSecretRecord', 'BaseSecretRecord'] class ChangeSecretAutomation(ChangeSecretMixin, AccountBaseAutomation): @@ -24,30 +25,47 @@ class ChangeSecretAutomation(ChangeSecretMixin, AccountBaseAutomation): def to_attr_json(self): attr_json = super().to_attr_json() attr_json.update({ - 'recipients': { - str(recipient.id): (str(recipient), bool(recipient.secret_key)) - for recipient in self.recipients.all() - } + 'recipients': [str(r.id) for r in self.recipients.all()] }) return attr_json -class ChangeSecretRecord(JMSBaseModel): - execution = models.ForeignKey('accounts.AutomationExecution', on_delete=models.SET_NULL, null=True) - asset = models.ForeignKey('assets.Asset', on_delete=models.SET_NULL, null=True) - account = models.ForeignKey('accounts.Account', on_delete=models.SET_NULL, null=True) - old_secret = fields.EncryptTextField(blank=True, null=True, verbose_name=_('Old secret')) - new_secret = fields.EncryptTextField(blank=True, null=True, verbose_name=_('New secret')) - date_started = models.DateTimeField(blank=True, null=True, verbose_name=_('Date started')) - date_finished = models.DateTimeField(blank=True, null=True, verbose_name=_('Date finished')) +class BaseSecretRecord(JMSBaseModel): + account = models.ForeignKey( + 'accounts.Account', on_delete=models.SET_NULL, + null=True, related_name='%(class)ss' + ) + asset = models.ForeignKey( + 'assets.Asset', on_delete=models.SET_NULL, + null=True, related_name='asset_%(class)ss' + ) + execution = models.ForeignKey( + 'accounts.AutomationExecution', on_delete=models.SET_NULL, + null=True, related_name='execution_%(class)ss', + ) + date_finished = models.DateTimeField(blank=True, null=True, verbose_name=_('Date finished'), db_index=True) status = models.CharField( max_length=16, verbose_name=_('Status'), default=ChangeSecretRecordStatusChoice.pending.value ) error = models.TextField(blank=True, null=True, verbose_name=_('Error')) class Meta: - ordering = ('-date_created',) - verbose_name = _("Change secret record") + abstract = True def __str__(self): return f'{self.account.username}@{self.asset}' + + @classmethod + def get_valid_records(cls): + return cls.objects.exclude( + Q(execution__isnull=True) | Q(asset__isnull=True) | Q(account__isnull=True) + ) + + +class ChangeSecretRecord(BaseSecretRecord): + old_secret = fields.EncryptTextField(blank=True, null=True, verbose_name=_('Old secret')) + new_secret = fields.EncryptTextField(blank=True, null=True, verbose_name=_('New secret')) + ignore_fail = models.BooleanField(default=False, verbose_name=_('Ignore fail')) + + class Meta: + verbose_name = _("Change secret record") diff --git a/apps/accounts/models/automations/check_account.py b/apps/accounts/models/automations/check_account.py new file mode 100644 index 000000000..4f2a937f1 --- /dev/null +++ b/apps/accounts/models/automations/check_account.py @@ -0,0 +1,159 @@ +from itertools import islice + +from django.db import models +from django.db.models import TextChoices +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from common.const import ConfirmOrIgnore +from common.db.models import JMSBaseModel +from orgs.mixins.models import JMSOrgBaseModel +from .base import AccountBaseAutomation +from ...const import AutomationTypes + +__all__ = ['CheckAccountAutomation', 'AccountRisk', 'RiskChoice', 'CheckAccountEngine'] + + +class CheckAccountAutomation(AccountBaseAutomation): + engines = models.JSONField(default=list, verbose_name=_('Engines')) + recipients = models.ManyToManyField('users.User', verbose_name=_("Recipient"), blank=True) + + def to_attr_json(self): + attr_json = super().to_attr_json() + attr_json.update({ + 'engines': self.engines, + 'recipients': [str(user.id) for user in self.recipients.all()] + }) + return attr_json + + def save(self, *args, **kwargs): + self.type = AutomationTypes.check_account + super().save(*args, **kwargs) + + class Meta: + verbose_name = _('account check automation') + permissions = [ + ('view_checkaccountexecution', _('Can view check account execution')), + ('add_checkaccountexecution', _('Can add check account execution')), + ] + + +class RiskChoice(TextChoices): + # 依赖自动发现的 + long_time_no_login = 'long_time_no_login', _('Long time no login') # 好久没登录的账号, 禁用、删除 + new_found = 'new_found', _('New found') # 未被纳管的账号, 纳管, 删除, 禁用 + group_changed = 'groups_changed', _('Groups change') # 组变更, 确认 + sudo_changed = 'sudoers_changed', _('Sudo changed') # sudo 变更, 确认 + authorized_keys_changed = 'authorized_keys_changed', _('Authorized keys changed') # authorized_keys 变更, 确认 + account_deleted = 'account_deleted', _('Account delete') # 账号被删除, 确认 + password_expired = 'password_expired', _('Password expired') # 密码过期, 修改密码 + long_time_password = 'long_time_password', _('Long time no change') # 好久没改密码的账号, 改密码 + + weak_password = 'weak_password', _('Weak password') # 弱密码, 改密 + leaked_password = 'leaked_password', _('Leaked password') # 可能泄露的密码, 改密 + repeated_password = 'repeated_password', _('Repeated password') # 重复度高的密码, 改密 + password_error = 'password_error', _('Password error') # 密码错误, 修改账号 + no_admin_account = 'no_admin_account', _('No admin account') # 无管理员账号, 设置账号 + others = 'others', _('Others') # 其他风险, 确认 + + +class AccountRisk(JMSOrgBaseModel): + asset = models.ForeignKey( + 'assets.Asset', on_delete=models.CASCADE, related_name='risks', verbose_name=_('Asset') + ) + username = models.CharField(max_length=32, verbose_name=_('Username')) + account = models.ForeignKey( + 'accounts.Account', on_delete=models.CASCADE, related_name='risks', + verbose_name=_('Account'), null=True + ) + gathered_account = models.ForeignKey( + 'accounts.GatheredAccount', on_delete=models.CASCADE, + related_name='risks', null=True + ) + risk = models.CharField(max_length=128, verbose_name=_('Risk'), choices=RiskChoice.choices) + status = models.CharField(max_length=32, choices=ConfirmOrIgnore.choices, default=ConfirmOrIgnore.pending, + blank=True, verbose_name=_('Status')) + details = models.JSONField(default=list, verbose_name=_('Details')) + + class Meta: + verbose_name = _('Account risk') + unique_together = ('asset', 'username', 'risk') + + def __str__(self): + return f"{self.username}@{self.asset} - {self.risk}" + + def set_status(self, status, user): + self.status = status + self.details.append({'date': timezone.now().isoformat(), 'message': f'{user.username} set status to {status}'}) + self.save() + + def update_details(self, message, user): + self.details.append({'date': timezone.now().isoformat(), 'message': f'{user.username} {message}'}) + self.save(update_fields=['details']) + + @classmethod + def gen_fake_data(cls, count=1000, batch_size=50): + from assets.models import Asset + from accounts.models import Account + + assets = Asset.objects.all() + accounts = Account.objects.all() + + counter = iter(range(count)) + while True: + batch = list(islice(counter, batch_size)) + if not batch: + break + + to_create = [] + for i in batch: + asset = assets[i % len(assets)] + account = accounts[i % len(accounts)] + risk = RiskChoice.choices[i % len(RiskChoice.choices)][0] + to_create.append(cls(asset=asset, username=account.username, risk=risk)) + + cls.objects.bulk_create(to_create) + + +class CheckAccountEngine(JMSBaseModel): + name = models.CharField(max_length=128, verbose_name=_('Name'), unique=True) + slug = models.SlugField(max_length=128, verbose_name=_('Slug'), unique=True) + + def __str__(self): + return self.name + + @staticmethod + def get_default_engines(): + data = [ + { + "id": "00000000-0000-0000-0000-000000000001", + "slug": "check_gathered_account", + "name": _("Check the discovered accounts"), + "comment": _( + "Perform checks and analyses based on automatically discovered account results, " + "including user groups, public keys, sudoers, and other information" + ) + }, + { + "id": "00000000-0000-0000-0000-000000000002", + "slug": "check_account_secret", + "name": _("Check the strength of your account and password"), + "comment": _( + "Perform checks and analyses based on the security of account passwords, " + "including password strength, leakage, etc." + ) + }, + { + "id": "00000000-0000-0000-0000-000000000003", + "slug": "check_account_repeat", + "name": _("Check if the account and password are repeated"), + "comment": _("Check if the account is the same as other accounts") + }, + { + "id": "00000000-0000-0000-0000-000000000004", + "slug": "check_account_leak", + "name": _("Check whether the account password is a common password"), + "comment": _("Check whether the account password is a commonly leaked password") + }, + ] + return data diff --git a/apps/accounts/models/automations/gather_account.py b/apps/accounts/models/automations/gather_account.py index 6a40a0498..cfb930f75 100644 --- a/apps/accounts/models/automations/gather_account.py +++ b/apps/accounts/models/automations/gather_account.py @@ -4,6 +4,8 @@ from django.utils.translation import gettext_lazy as _ from accounts.const import AutomationTypes, Source from accounts.models import Account +from common.const import ConfirmOrIgnore +from common.utils.timezone import is_date_more_than from orgs.mixins.models import JMSOrgBaseModel from .base import AccountBaseAutomation @@ -11,19 +13,54 @@ __all__ = ['GatherAccountsAutomation', 'GatheredAccount'] class GatheredAccount(JMSOrgBaseModel): - present = models.BooleanField(default=True, verbose_name=_("Present")) - date_last_login = models.DateTimeField(null=True, verbose_name=_("Date login")) asset = models.ForeignKey('assets.Asset', on_delete=models.CASCADE, verbose_name=_("Asset")) username = models.CharField(max_length=32, blank=True, db_index=True, verbose_name=_('Username')) - address_last_login = models.CharField(max_length=39, default='', verbose_name=_("Address login")) + address_last_login = models.CharField(null=True, max_length=39, default='', verbose_name=_("Address login")) + date_last_login = models.DateTimeField(null=True, verbose_name=_("Date login")) + remote_present = models.BooleanField(default=True, verbose_name=_("Remote present")) # 远端资产上是否还存在 + present = models.BooleanField(default=False, verbose_name=_("Present")) # 系统资产上是否还存在 + date_password_change = models.DateTimeField(null=True, verbose_name=_("Date change password")) + date_password_expired = models.DateTimeField(null=True, verbose_name=_("Date password expired")) + status = models.CharField(max_length=32, default=ConfirmOrIgnore.pending, blank=True, + choices=ConfirmOrIgnore.choices, verbose_name=_("Status")) + detail = models.JSONField(default=dict, blank=True, verbose_name=_("Detail")) @property def address(self): return self.asset.address - @staticmethod - def sync_accounts(gathered_accounts): + @classmethod + def update_exists_accounts(cls, gathered_account, accounts): + if not gathered_account.date_last_login: + return + + for account in accounts: + # 这里是否可以考虑,标记成未从堡垒机登录风险 ? + if is_date_more_than(gathered_account.date_last_login, account.date_last_login, '5m'): + account.date_last_login = gathered_account.date_last_login + account.login_by = '{}({})'.format('unknown', gathered_account.address_last_login) + account.save(update_fields=['date_last_login', 'login_by']) + + @classmethod + def create_accounts(cls, gathered_account): account_objs = [] + asset_id = gathered_account.asset_id + username = gathered_account.username + account = Account( + asset_id=asset_id, username=username, + name=username, source=Source.DISCOVERY, + date_last_login=gathered_account.date_last_login, + ) + account_objs.append(account) + Account.objects.bulk_create(account_objs) + gathered_account.status = ConfirmOrIgnore.confirmed + gathered_account.save(update_fields=['status']) + + @classmethod + def sync_accounts(cls, gathered_accounts, auto_create=True): + """ + 更新为已存在的账号,或者创建新的账号, 原来的 sync 重构了,如果存在则自动更新一些信息 + """ for gathered_account in gathered_accounts: asset_id = gathered_account.asset_id username = gathered_account.username @@ -31,14 +68,11 @@ class GatheredAccount(JMSOrgBaseModel): Q(asset_id=asset_id, username=username) | Q(asset_id=asset_id, name=username) ) + if accounts.exists(): - continue - account = Account( - asset_id=asset_id, username=username, - name=username, source=Source.COLLECTED - ) - account_objs.append(account) - Account.objects.bulk_create(account_objs) + cls.update_exists_accounts(gathered_account, accounts) + elif auto_create: + cls.create_accounts(gathered_account) class Meta: verbose_name = _("Gather asset accounts") @@ -56,11 +90,13 @@ class GatherAccountsAutomation(AccountBaseAutomation): default=False, blank=True, verbose_name=_("Is sync account") ) recipients = models.ManyToManyField('users.User', verbose_name=_("Recipient"), blank=True) + check_risk = models.BooleanField(default=True, verbose_name=_("Check risk")) def to_attr_json(self): attr_json = super().to_attr_json() attr_json.update({ 'is_sync_account': self.is_sync_account, + 'check_risk': self.check_risk, 'recipients': [ str(recipient.id) for recipient in self.recipients.all() ] diff --git a/apps/accounts/models/automations/push_account.py b/apps/accounts/models/automations/push_account.py index c5d47d451..768a07d35 100644 --- a/apps/accounts/models/automations/push_account.py +++ b/apps/accounts/models/automations/push_account.py @@ -1,44 +1,32 @@ from django.conf import settings -from django.db import models from django.utils.translation import gettext_lazy as _ from accounts.const import AutomationTypes, SecretType from accounts.models import Account -from .base import AccountBaseAutomation -from .change_secret import ChangeSecretMixin +from .base import AccountBaseAutomation, ChangeSecretMixin +from .change_secret import BaseSecretRecord -__all__ = ['PushAccountAutomation'] +__all__ = ['PushAccountAutomation', 'PushSecretRecord'] class PushAccountAutomation(ChangeSecretMixin, AccountBaseAutomation): - triggers = models.JSONField(max_length=16, default=list, verbose_name=_('Triggers')) - username = models.CharField(max_length=128, verbose_name=_('Username')) - action = models.CharField(max_length=16, verbose_name=_('Action')) - def create_nonlocal_accounts(self, usernames, asset): + def gen_nonlocal_accounts(self, usernames, asset): secret_type = self.secret_type account_usernames = asset.accounts \ .filter(secret_type=self.secret_type) \ .values_list('username', flat=True) create_usernames = set(usernames) - set(account_usernames) - create_account_objs = [ + + create_accounts = [ Account( name=f"{username}-{secret_type}" if secret_type != SecretType.PASSWORD else username, - username=username, + username=username, secret=self.get_secret(), secret_type=secret_type, asset=asset, ) for username in create_usernames ] - Account.objects.bulk_create(create_account_objs) - - @property - def dynamic_username(self): - return self.username == '@USER' - - @dynamic_username.setter - def dynamic_username(self, value): - if value: - self.username = '@USER' + return create_accounts def save(self, *args, **kwargs): self.type = AutomationTypes.push_account @@ -46,13 +34,10 @@ class PushAccountAutomation(ChangeSecretMixin, AccountBaseAutomation): self.is_periodic = False super().save(*args, **kwargs) - def to_attr_json(self): - attr_json = super().to_attr_json() - attr_json.update({ - 'username': self.username, - 'params': self.params, - }) - return attr_json - class Meta: verbose_name = _("Push asset account") + + +class PushSecretRecord(BaseSecretRecord): + class Meta: + verbose_name = _("Push secret record") diff --git a/apps/accounts/models/base.py b/apps/accounts/models/base.py index 4b55752d6..583ebd318 100644 --- a/apps/accounts/models/base.py +++ b/apps/accounts/models/base.py @@ -54,10 +54,9 @@ class SecretWithRandomMixin(models.Model): ) def get_secret(self): - if self.secret_strategy == 'random': - return self.secret_generator.get_secret() - else: + if self.secret_strategy == SecretStrategy.custom: return self.secret + return self.secret_generator.get_secret() class BaseAccount(VaultModelMixin, JMSOrgBaseModel): diff --git a/apps/accounts/models/template.py b/apps/accounts/models/template.py index 8d5be852a..6a24caf85 100644 --- a/apps/accounts/models/template.py +++ b/apps/accounts/models/template.py @@ -86,3 +86,7 @@ class AccountTemplate(LabeledMixin, BaseAccount, SecretWithRandomMixin): """ 批量同步账号密码 """ self.bulk_update_accounts(accounts) self.bulk_create_history_accounts(accounts, user_id) + + def save(self, *args, **kwargs): + self.secret = self.get_secret() + super().save(*args, **kwargs) diff --git a/apps/accounts/notifications.py b/apps/accounts/notifications.py index e981443a0..20807ba97 100644 --- a/apps/accounts/notifications.py +++ b/apps/accounts/notifications.py @@ -1,5 +1,6 @@ from django.template.loader import render_to_string from django.utils.translation import gettext_lazy as _ +from premailer import transform from accounts.models import ChangeSecretRecord from common.tasks import send_mail_attachment_async, upload_backup_to_obj_storage @@ -8,7 +9,7 @@ from terminal.models.component.storage import ReplayStorage from users.models import User -class AccountBackupExecutionTaskMsg(object): +class AccountBackupExecutionTaskMsg: subject = _('Notification of account backup route task results') def __init__(self, name: str, user: User): @@ -33,7 +34,7 @@ class AccountBackupExecutionTaskMsg(object): ) -class AccountBackupByObjStorageExecutionTaskMsg(object): +class AccountBackupByObjStorageExecutionTaskMsg: subject = _('Notification of account backup route task results') def __init__(self, name: str, obj_storage: ReplayStorage): @@ -52,7 +53,7 @@ class AccountBackupByObjStorageExecutionTaskMsg(object): ) -class ChangeSecretExecutionTaskMsg(object): +class ChangeSecretExecutionTaskMsg: subject = _('Notification of implementation result of encryption change plan') def __init__(self, name: str, user: User, summary): @@ -101,24 +102,19 @@ class GatherAccountChangeMsg(UserMessage): return cls(user, {}) -class ChangeSecretFailedMsg(UserMessage): +class ChangeSecretReportMsg(UserMessage): subject = _('Change secret or push account failed information') - def __init__(self, name, execution_id, user, asset_account_errors: list): - self.name = name - self.execution_id = execution_id - self.asset_account_errors = asset_account_errors + def __init__(self, user, context: dict): + self.context = context super().__init__(user) def get_html_msg(self) -> dict: - context = { - 'name': self.name, - 'recipient': self.user, - 'execution_id': self.execution_id, - 'asset_account_errors': self.asset_account_errors - } - message = render_to_string('accounts/change_secret_failed_info.html', context) - + report = render_to_string( + 'accounts/change_secret_report.html', + self.context + ) + message = transform(report) return { 'subject': str(self.subject), 'message': message @@ -130,4 +126,4 @@ class ChangeSecretFailedMsg(UserMessage): user = User.objects.first() record = ChangeSecretRecord.objects.first() execution_id = str(record.execution_id) - return cls(name, execution_id, user, []) + return cls(user, {}) diff --git a/apps/accounts/risk_handlers.py b/apps/accounts/risk_handlers.py new file mode 100644 index 000000000..301e2e8ad --- /dev/null +++ b/apps/accounts/risk_handlers.py @@ -0,0 +1,174 @@ +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ +from rest_framework.serializers import ValidationError + +from accounts.const import AutomationTypes, Source +from accounts.models import ( + GatheredAccount, + AccountRisk, + SecretType, + AutomationExecution, + RiskChoice +) +from common.const import ConfirmOrIgnore +from common.utils import random_string + +TYPE_CHOICES = [ + ("ignore", _("Ignore")), + ("reopen", _("Reopen")), + ("close", _("Close")), + ("disable_remote", _("Disable remote")), + ("delete_remote", _("Delete remote")), + ("delete_both", _("Delete remote")), + ("add_account", _("Add account")), + ("change_password_add", _("Change password and Add")), + ("change_password", _("Change password")) +] + + +class RiskHandler: + def __init__(self, asset, username, request=None, risk=""): + self.asset = asset + self.username = username + self.request = request + self.risk = risk + + def handle(self, tp, risk=""): + self.risk = risk + attr = f"handle_{tp}" + + if not hasattr(self, attr): + raise ValidationError(f"Invalid risk type: {tp}") + + getattr(self, attr)() + risk = self.update_risk_if_need(tp) + return risk + + def update_risk_if_need(self, tp): + r = self.get_risk() + if not r: + return + if tp == "ignore": + status = ConfirmOrIgnore.ignored + elif tp == "reopen": + status = ConfirmOrIgnore.pending + else: + status = ConfirmOrIgnore.confirmed + r.details.append({**self.process_detail, "action": tp, "status": status}) + r.status = status + r.save() + return r + + def get_risk(self): + r = AccountRisk.objects.filter(asset=self.asset, username=self.username) + if self.risk: + r = r.filter(risk=self.risk) + return r.first() + + def handle_ignore(self): + (GatheredAccount.objects + .filter(asset=self.asset, username=self.username) + .update(status=ConfirmOrIgnore.ignored)) + + def handle_reopen(self): + pass + + def handle_close(self): + pass + + def handle_review(self): + pass + + @property + def process_detail(self): + detail = { + "datetime": timezone.now().isoformat(), + "type": "process", + "processor": str(self.request.user), + } + if self.request and self.request.data and self.request.data.get('comment'): + detail['comment'] = self.request.data['comment'] + return detail + + def handle_add_account(self): + data = { + "username": self.username, + "name": self.username, + "secret_type": SecretType.PASSWORD, + "source": "collected", + } + self.asset.accounts.get_or_create(defaults=data, username=self.username) + GatheredAccount.objects.filter(asset=self.asset, username=self.username).update( + present=True, status=ConfirmOrIgnore.confirmed + ) + self.risk = RiskChoice.new_found + + def handle_disable_remote(self): + pass + + def handle_delete_remote(self): + self._handle_delete(delete="remote") + + def _handle_delete(self, delete="both"): + asset = self.asset + execution = AutomationExecution() + execution.snapshot = { + "assets": [str(asset.id)], + "accounts": [{"asset": str(asset.id), "username": self.username}], + "type": "remove_account", + "name": "Remove remote account: {}@{}".format(self.username, asset.name), + "delete": delete, + "risk": self.risk + } + execution.save() + execution.start() + return execution.summary + + def handle_delete_both(self): + self._handle_delete(delete="both") + + def handle_change_password(self): + asset = self.asset + execution = AutomationExecution() + execution.snapshot = { + "assets": [str(asset.id)], + "accounts": [self.username], + "type": AutomationTypes.change_secret, + "secret_type": "password", + "secret_strategy": "random", + "name": "Change account password: {}@{}".format(self.username, asset.name), + } + execution.save() + execution.start() + return execution.summary + + def handle_change_password_add(self): + asset = self.asset + secret_type = SecretType.PASSWORD + secret = random_string(30) + account_data = { + "username": self.username, + "name": f'{self.username}-{secret_type}', + "secret_type": SecretType.PASSWORD, + "source": Source.DISCOVERY, + "asset": asset, + "secret": secret + } + account, _ = self.asset.accounts.get_or_create(defaults=account_data, username=self.username) + execution = AutomationExecution() + execution.snapshot = { + "assets": [str(asset.id)], + "accounts": [str(account.id)], + "type": AutomationTypes.push_account, + "secret_type": secret_type, + 'nodes': [], + 'org_id': self.asset.org_id, + "secret_strategy": "random", + "secret": secret, + 'ssh_key_change_strategy': 'set_jms', + 'check_conn_after_change': True, + "name": "Push account password: {}@{}".format(self.username, asset.name), + } + execution.save() + execution.start() + return execution.summary diff --git a/apps/accounts/serializers/account/__init__.py b/apps/accounts/serializers/account/__init__.py index 207029047..28bd606ec 100644 --- a/apps/accounts/serializers/account/__init__.py +++ b/apps/accounts/serializers/account/__init__.py @@ -1,6 +1,5 @@ from .account import * -from .backup import * from .base import * -from .gathered_account import * +from .service import * from .template import * from .virtual import * diff --git a/apps/accounts/serializers/account/account.py b/apps/accounts/serializers/account/account.py index 083abcdd7..844abce9c 100644 --- a/apps/accounts/serializers/account/account.py +++ b/apps/accounts/serializers/account/account.py @@ -202,7 +202,7 @@ class AccountCreateUpdateSerializerMixin(serializers.Serializer): class AccountAssetSerializer(serializers.ModelSerializer): - platform = ObjectRelatedField(read_only=True) + platform = ObjectRelatedField(read_only=True, attrs=('id', 'name', 'type')) category = LabeledChoiceField(choices=Category.choices, read_only=True, label=_('Category')) type = LabeledChoiceField(choices=AllTypes.choices(), read_only=True, label=_('Type')) @@ -235,13 +235,15 @@ class AccountSerializer(AccountCreateUpdateSerializerMixin, BaseAccountSerialize class Meta(BaseAccountSerializer.Meta): model = Account + automation_fields = [ + 'date_last_login', 'login_by', 'date_verified', 'connectivity', + 'date_change_secret', 'change_secret_status' + ] fields = BaseAccountSerializer.Meta.fields + [ 'su_from', 'asset', 'version', - 'source', 'source_id', 'connectivity', - ] + AccountCreateUpdateSerializerMixin.Meta.fields - read_only_fields = BaseAccountSerializer.Meta.read_only_fields + [ - 'connectivity' - ] + 'source', 'source_id', 'secret_reset', + ] + AccountCreateUpdateSerializerMixin.Meta.fields + automation_fields + read_only_fields = BaseAccountSerializer.Meta.read_only_fields + automation_fields extra_kwargs = { **BaseAccountSerializer.Meta.extra_kwargs, 'name': {'required': False}, diff --git a/apps/accounts/serializers/account/backup.py b/apps/accounts/serializers/account/backup.py deleted file mode 100644 index d2aaf7ed1..000000000 --- a/apps/accounts/serializers/account/backup.py +++ /dev/null @@ -1,56 +0,0 @@ -# -*- coding: utf-8 -*- -# -from django.utils.translation import gettext_lazy as _ -from rest_framework import serializers - -from accounts.models import AccountBackupAutomation, AccountBackupExecution -from common.const.choices import Trigger -from common.serializers.fields import LabeledChoiceField, EncryptedField -from common.utils import get_logger -from ops.mixin import PeriodTaskSerializerMixin -from orgs.mixins.serializers import BulkOrgResourceModelSerializer - -logger = get_logger(__file__) - -__all__ = ['AccountBackupSerializer', 'AccountBackupPlanExecutionSerializer'] - - -class AccountBackupSerializer(PeriodTaskSerializerMixin, BulkOrgResourceModelSerializer): - zip_encrypt_password = EncryptedField( - label=_('Zip Encrypt Password'), required=False, max_length=40960, allow_blank=True, - allow_null=True, write_only=True, - ) - - class Meta: - model = AccountBackupAutomation - read_only_fields = [ - 'date_created', 'date_updated', 'created_by', - 'periodic_display', 'executed_amount' - ] - fields = read_only_fields + [ - 'id', 'name', 'is_periodic', 'interval', 'crontab', - 'comment', 'types', 'recipients_part_one', 'recipients_part_two', 'backup_type', - 'is_password_divided_by_email', 'is_password_divided_by_obj_storage', 'obj_recipients_part_one', - 'obj_recipients_part_two', 'zip_encrypt_password' - ] - extra_kwargs = { - 'name': {'required': True}, - 'executed_amount': {'label': _('Executions')}, - 'recipients': { - 'label': _('Recipient'), - 'help_text': _('Currently only mail sending is supported') - }, - 'types': {'label': _('Asset type')} - } - - -class AccountBackupPlanExecutionSerializer(serializers.ModelSerializer): - trigger = LabeledChoiceField(choices=Trigger.choices, label=_("Trigger mode"), read_only=True) - - class Meta: - model = AccountBackupExecution - read_only_fields = [ - 'id', 'date_start', 'timedelta', 'snapshot', - 'trigger', 'reason', 'is_success', 'org_id' - ] - fields = read_only_fields + ['plan'] diff --git a/apps/accounts/serializers/account/base.py b/apps/accounts/serializers/account/base.py index 7340b09df..a6eacd83b 100644 --- a/apps/accounts/serializers/account/base.py +++ b/apps/accounts/serializers/account/base.py @@ -74,20 +74,13 @@ class BaseAccountSerializer( model = BaseAccount fields_mini = ["id", "name", "username"] fields_small = fields_mini + [ - "secret_type", - "secret", - "passphrase", - "privileged", - "is_active", - "spec_info", + "secret_type", "secret", "passphrase", + "privileged", "is_active", "spec_info", ] fields_other = ["created_by", "date_created", "date_updated", "comment"] fields = fields_small + fields_other + ["labels"] read_only_fields = [ - "spec_info", - "date_verified", - "created_by", - "date_created", + "spec_info", "created_by", "date_created", ] extra_kwargs = { "spec_info": {"label": _("Spec info")}, diff --git a/apps/accounts/serializers/account/gathered_account.py b/apps/accounts/serializers/account/gathered_account.py deleted file mode 100644 index a36bddc5e..000000000 --- a/apps/accounts/serializers/account/gathered_account.py +++ /dev/null @@ -1,23 +0,0 @@ -from django.utils.translation import gettext_lazy as _ - -from accounts.models import GatheredAccount -from orgs.mixins.serializers import BulkOrgResourceModelSerializer -from .account import AccountAssetSerializer -from .base import BaseAccountSerializer - - -class GatheredAccountSerializer(BulkOrgResourceModelSerializer): - asset = AccountAssetSerializer(label=_('Asset')) - - class Meta(BaseAccountSerializer.Meta): - model = GatheredAccount - fields = [ - 'id', 'present', 'asset', 'username', - 'date_updated', 'address_last_login', 'date_last_login' - ] - - @classmethod - def setup_eager_loading(cls, queryset): - """ Perform necessary eager loading of data. """ - queryset = queryset.prefetch_related('asset', 'asset__platform') - return queryset diff --git a/apps/accounts/serializers/account/service.py b/apps/accounts/serializers/account/service.py new file mode 100644 index 000000000..109f7edcc --- /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 IntegrationApplication +from acls.serializers.rules import ip_group_child_validator, ip_group_help_text +from common.serializers.fields import JSONManyToManyField + + +class IntegrationApplicationSerializer(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 = IntegrationApplication + fields_mini = ['id', 'name'] + fields_small = fields_mini + ['logo', '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'].required = False + + +class IntegrationAccountSecretSerializer(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/serializers/account/template.py b/apps/accounts/serializers/account/template.py index 3df2d5b74..bb6037e33 100644 --- a/apps/accounts/serializers/account/template.py +++ b/apps/accounts/serializers/account/template.py @@ -1,9 +1,7 @@ from django.utils.translation import gettext_lazy as _ from rest_framework import serializers -from accounts.const import SecretStrategy, SecretType from accounts.models import AccountTemplate -from accounts.utils import SecretGenerator from common.serializers import SecretReadableMixin from common.serializers.fields import ObjectRelatedField from .base import BaseAccountSerializer @@ -58,21 +56,6 @@ class AccountTemplateSerializer(BaseAccountSerializer): } fields_unimport_template = ['push_params'] - @staticmethod - def generate_secret(attrs): - secret_type = attrs.get('secret_type', SecretType.PASSWORD) - secret_strategy = attrs.get('secret_strategy', SecretStrategy.custom) - password_rules = attrs.get('password_rules') - if secret_strategy != SecretStrategy.random: - return - generator = SecretGenerator(secret_strategy, secret_type, password_rules) - attrs['secret'] = generator.get_secret() - - def validate(self, attrs): - attrs = super().validate(attrs) - self.generate_secret(attrs) - return attrs - class AccountTemplateSecretSerializer(SecretReadableMixin, AccountTemplateSerializer): class Meta(AccountTemplateSerializer.Meta): diff --git a/apps/accounts/serializers/automations/__init__.py b/apps/accounts/serializers/automations/__init__.py index 2b0aa0029..61035a089 100644 --- a/apps/accounts/serializers/automations/__init__.py +++ b/apps/accounts/serializers/automations/__init__.py @@ -1,4 +1,6 @@ +from .backup import * from .base import * from .change_secret import * -from .gather_accounts import * +from .check_account import * +from .gather_account import * from .push_account import * diff --git a/apps/accounts/serializers/automations/backup.py b/apps/accounts/serializers/automations/backup.py new file mode 100644 index 000000000..eec829663 --- /dev/null +++ b/apps/accounts/serializers/automations/backup.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +# +from django.utils.translation import gettext_lazy as _ + +from accounts.const import AutomationTypes +from accounts.models import BackupAccountAutomation +from common.serializers.fields import EncryptedField +from common.utils import get_logger +from .base import BaseAutomationSerializer + +logger = get_logger(__file__) + +__all__ = ['BackupAccountSerializer'] + + +class BackupAccountSerializer(BaseAutomationSerializer): + zip_encrypt_password = EncryptedField( + label=_('Zip Encrypt Password'), required=False, max_length=40960, allow_blank=True, + allow_null=True, write_only=True, + ) + + class Meta: + model = BackupAccountAutomation + read_only_fields = BaseAutomationSerializer.Meta.read_only_fields + fields = BaseAutomationSerializer.Meta.fields + read_only_fields + [ + 'types', 'recipients_part_one', 'recipients_part_two', 'backup_type', + 'is_password_divided_by_email', 'is_password_divided_by_obj_storage', + 'obj_recipients_part_one', 'obj_recipients_part_two', 'zip_encrypt_password' + ] + extra_kwargs = { + 'name': {'required': True}, + 'obj_recipients_part_one': { + 'label': _('Recipient part one'), 'help_text': _( + "Currently only mail sending is supported" + )}, + 'obj_recipients_part_two': { + 'label': _('Recipient part two'), 'help_text': _( + "Currently only mail sending is supported" + )}, + 'types': {'label': _('Asset type')} + } + + @property + def model_type(self): + return AutomationTypes.backup_account diff --git a/apps/accounts/serializers/automations/base.py b/apps/accounts/serializers/automations/base.py index 086f1b297..ab79e23a3 100644 --- a/apps/accounts/serializers/automations/base.py +++ b/apps/accounts/serializers/automations/base.py @@ -1,43 +1,20 @@ from django.utils.translation import gettext_lazy as _ from rest_framework import serializers -from accounts.models import AutomationExecution from assets.const import AutomationTypes -from assets.models import Asset, Node, BaseAutomation -from common.const.choices import Trigger -from common.serializers.fields import ObjectRelatedField, LabeledChoiceField +from assets.models import BaseAutomation +from assets.serializers.automations import AutomationExecutionSerializer as AssetAutomationExecutionSerializer +from assets.serializers.automations import BaseAutomationSerializer as AssetBaseAutomationSerializer from common.utils import get_logger -from ops.mixin import PeriodTaskSerializerMixin -from orgs.mixins.serializers import BulkOrgResourceModelSerializer logger = get_logger(__file__) __all__ = [ 'BaseAutomationSerializer', 'AutomationExecutionSerializer', - 'UpdateAssetSerializer', 'UpdateNodeSerializer', 'AutomationAssetsSerializer', ] -class BaseAutomationSerializer(PeriodTaskSerializerMixin, BulkOrgResourceModelSerializer): - assets = ObjectRelatedField(many=True, required=False, queryset=Asset.objects, label=_('Assets')) - nodes = ObjectRelatedField(many=True, required=False, queryset=Node.objects, label=_('Nodes')) - is_periodic = serializers.BooleanField(default=False, required=False, label=_("Periodic perform")) - - class Meta: - read_only_fields = [ - 'date_created', 'date_updated', 'created_by', - 'periodic_display', 'executed_amount' - ] - fields = read_only_fields + [ - 'id', 'name', 'is_periodic', 'interval', 'crontab', 'comment', - 'type', 'accounts', 'nodes', 'assets', 'is_active', - ] - extra_kwargs = { - 'name': {'required': True}, - 'type': {'read_only': True}, - 'executed_amount': {'label': _('Executions')}, - } - +class BaseAutomationSerializer(AssetBaseAutomationSerializer): def validate_name(self, name): if self.instance and self.instance.name == name: return name @@ -50,17 +27,8 @@ class BaseAutomationSerializer(PeriodTaskSerializerMixin, BulkOrgResourceModelSe raise NotImplementedError -class AutomationExecutionSerializer(serializers.ModelSerializer): +class AutomationExecutionSerializer(AssetAutomationExecutionSerializer): snapshot = serializers.SerializerMethodField(label=_('Automation snapshot')) - type = serializers.ChoiceField(choices=AutomationTypes.choices, write_only=True, label=_('Type')) - trigger = LabeledChoiceField(choices=Trigger.choices, read_only=True, label=_("Trigger mode")) - - class Meta: - model = AutomationExecution - read_only_fields = [ - 'trigger', 'date_start', 'date_finished', 'snapshot', 'status' - ] - fields = ['id', 'automation', 'type'] + read_only_fields @staticmethod def get_snapshot(obj): @@ -77,22 +45,3 @@ class AutomationExecutionSerializer(serializers.ModelSerializer): 'type_display': type_display, } return snapshot - - -class UpdateAssetSerializer(serializers.ModelSerializer): - class Meta: - model = BaseAutomation - fields = ['id', 'assets'] - - -class UpdateNodeSerializer(serializers.ModelSerializer): - class Meta: - model = BaseAutomation - fields = ['id', 'nodes'] - - -class AutomationAssetsSerializer(serializers.ModelSerializer): - class Meta: - model = Asset - only_fields = ['id', 'name', 'address'] - fields = tuple(only_fields) diff --git a/apps/accounts/serializers/automations/change_secret.py b/apps/accounts/serializers/automations/change_secret.py index 2334880b9..40092c1ee 100644 --- a/apps/accounts/serializers/automations/change_secret.py +++ b/apps/accounts/serializers/automations/change_secret.py @@ -41,7 +41,8 @@ class ChangeSecretAutomationSerializer(AuthValidateMixin, BaseAutomationSerializ choices=SecretStrategy.choices, required=True, label=_('Secret strategy') ) ssh_key_change_strategy = LabeledChoiceField( - choices=SSHKeyStrategy.choices, required=False, label=_('SSH Key strategy') + choices=SSHKeyStrategy.choices, required=False, label=_('SSH Key strategy'), + default="set_jms" ) password_rules = PasswordRulesSerializer(required=False, label=_('Password rules')) secret_type = LabeledChoiceField(choices=get_secret_types(), required=True, label=_('Secret type')) @@ -51,13 +52,14 @@ class ChangeSecretAutomationSerializer(AuthValidateMixin, BaseAutomationSerializ read_only_fields = BaseAutomationSerializer.Meta.read_only_fields fields = BaseAutomationSerializer.Meta.fields + read_only_fields + [ 'secret_type', 'secret_strategy', 'secret', 'password_rules', - 'ssh_key_change_strategy', 'passphrase', 'recipients', 'params' + 'ssh_key_change_strategy', 'passphrase', 'recipients', 'params', 'check_conn_after_change' ] extra_kwargs = {**BaseAutomationSerializer.Meta.extra_kwargs, **{ 'accounts': {'required': True, 'help_text': _('Please enter your account username')}, 'recipients': {'label': _('Recipient'), 'help_text': _( "Currently only mail sending is supported" )}, + 'pre_recipients': {'help_text': _("Notification before execution")}, 'params': {'help_text': _( "Secret parameter settings, currently only effective for assets of the host type." )}, diff --git a/apps/accounts/serializers/automations/check_account.py b/apps/accounts/serializers/automations/check_account.py new file mode 100644 index 000000000..26136c34b --- /dev/null +++ b/apps/accounts/serializers/automations/check_account.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- +# +from django.utils.translation import gettext_lazy as _ +from rest_framework import serializers + +from accounts.const import AutomationTypes +from accounts.models import ( + CheckAccountAutomation, + AccountRisk, + RiskChoice, + CheckAccountEngine, +) +from accounts.risk_handlers import TYPE_CHOICES +from assets.models import Asset +from common.const import ConfirmOrIgnore +from common.serializers.fields import ObjectRelatedField, LabeledChoiceField +from common.utils import get_logger +from .base import BaseAutomationSerializer + +logger = get_logger(__file__) + +__all__ = [ + "CheckAccountAutomationSerializer", + "AccountRiskSerializer", + "CheckAccountEngineSerializer", + "AssetRiskSerializer", + "HandleRiskSerializer", +] + + +class AccountRiskSerializer(serializers.ModelSerializer): + asset = ObjectRelatedField( + queryset=Asset.objects.all(), required=False, label=_("Asset") + ) + risk = LabeledChoiceField( + choices=RiskChoice.choices, required=False, read_only=True, label=_("Risk") + ) + status = LabeledChoiceField( + choices=ConfirmOrIgnore.choices, required=False, label=_("Status") + ) + + class Meta: + model = AccountRisk + fields = [ + "id", "asset", "username", "risk", "status", + "date_created", "details", + ] + + @classmethod + def setup_eager_loading(cls, queryset): + return queryset.select_related("asset") + + +class RiskSummarySerializer(serializers.Serializer): + risk = serializers.CharField(max_length=128) + count = serializers.IntegerField() + + +class AssetRiskSerializer(serializers.Serializer): + id = serializers.CharField(max_length=128, required=False, source="asset__id") + name = serializers.CharField(max_length=128, required=False, source="asset__name") + address = serializers.CharField( + max_length=128, required=False, source="asset__address" + ) + platform = serializers.CharField( + max_length=128, required=False, source="asset__platform__name" + ) + risk_total = serializers.IntegerField() + risk_summary = serializers.SerializerMethodField() + + @staticmethod + def get_risk_summary(obj): + summary = {} + for risk in RiskChoice.choices: + summary[f"{risk[0]}_count"] = obj.get(f"{risk[0]}_count", 0) + return summary + + +class HandleRiskSerializer(serializers.Serializer): + username = serializers.CharField(max_length=128) + asset = serializers.PrimaryKeyRelatedField(queryset=Asset.objects) + action = serializers.ChoiceField(choices=TYPE_CHOICES) + risk = serializers.ChoiceField(choices=RiskChoice.choices, allow_null=True, allow_blank=True) + + +class CheckAccountAutomationSerializer(BaseAutomationSerializer): + class Meta: + model = CheckAccountAutomation + read_only_fields = BaseAutomationSerializer.Meta.read_only_fields + fields = ( + BaseAutomationSerializer.Meta.fields + + ["engines", "recipients"] + + read_only_fields + ) + extra_kwargs = BaseAutomationSerializer.Meta.extra_kwargs + + @property + def model_type(self): + return AutomationTypes.check_account + + @staticmethod + def validate_engines(engines): + valid_slugs = {i['slug'] for i in CheckAccountEngine.get_default_engines()} + + if not all(engine in valid_slugs for engine in engines): + raise serializers.ValidationError(_("Invalid engine id")) + + return engines + + +class CheckAccountEngineSerializer(serializers.ModelSerializer): + class Meta: + model = CheckAccountEngine + fields = ["id", "name", "slug", "comment"] + read_only_fields = ["slug"] diff --git a/apps/accounts/serializers/automations/gather_account.py b/apps/accounts/serializers/automations/gather_account.py new file mode 100644 index 000000000..8d3f48216 --- /dev/null +++ b/apps/accounts/serializers/automations/gather_account.py @@ -0,0 +1,91 @@ +from django.shortcuts import get_object_or_404 +from django.utils.translation import gettext_lazy as _ +from rest_framework import serializers + +from accounts.const import AutomationTypes, GatherAccountDetailField +from accounts.models import GatherAccountsAutomation +from accounts.models import GatheredAccount +from accounts.serializers.account.account import AccountAssetSerializer as _AccountAssetSerializer +from accounts.serializers.account.base import BaseAccountSerializer +from orgs.mixins.serializers import BulkOrgResourceModelSerializer +from .base import BaseAutomationSerializer + +__all__ = [ + 'DiscoverAccountSerializer', + 'DiscoverAccountActionSerializer', + 'DiscoverAccountAutomationSerializer', + 'DiscoverAccountDetailsSerializer' +] + + +class DiscoverAccountAutomationSerializer(BaseAutomationSerializer): + class Meta: + model = GatherAccountsAutomation + read_only_fields = BaseAutomationSerializer.Meta.read_only_fields + fields = (BaseAutomationSerializer.Meta.fields + + ['is_sync_account', 'check_risk', 'recipients'] + + read_only_fields) + extra_kwargs = { + 'check_risk': { + 'help_text': _('Whether to check the risk of the gathered accounts.'), + }, + **BaseAutomationSerializer.Meta.extra_kwargs + } + + @property + def model_type(self): + return AutomationTypes.gather_accounts + + +class AccountAssetSerializer(_AccountAssetSerializer): + class Meta(_AccountAssetSerializer.Meta): + ref_name = "GatheredAccountAssetSerializer" + fields = [f for f in _AccountAssetSerializer.Meta.fields if f != 'auto_config'] + + +class DiscoverAccountSerializer(BulkOrgResourceModelSerializer): + asset = AccountAssetSerializer(label=_('Asset')) + + class Meta(BaseAccountSerializer.Meta): + model = GatheredAccount + fields = [ + 'id', 'asset', 'username', + 'date_last_login', 'address_last_login', + 'remote_present', 'present', + 'date_updated', 'status', 'detail' + ] + read_only_fields = fields + + @classmethod + def setup_eager_loading(cls, queryset): + """ Perform necessary eager loading of data. """ + queryset = queryset.prefetch_related('asset', 'asset__platform') + return queryset + + +class DiscoverAccountActionSerializer(DiscoverAccountSerializer): + class Meta(DiscoverAccountSerializer.Meta): + read_only_fields = list(set(DiscoverAccountSerializer.Meta.read_only_fields) - {'status'}) + + +class DiscoverAccountDetailsSerializer(serializers.Serializer): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + request = self.context.get('request') + if not request: + return + + params = request.query_params + if params.get('format') == 'openapi': + return + + pk = request.parser_context['kwargs'].get('pk') + obj = get_object_or_404(GatheredAccount, pk=pk) + details = obj.detail + for key, value in details.items(): + field_dict = GatherAccountDetailField._member_map_ + label = field_dict[key].label if key in field_dict else key + if isinstance(value, bool): + self.fields[key] = serializers.BooleanField(label=label, read_only=True) + else: + self.fields[key] = serializers.CharField(label=label, read_only=True) diff --git a/apps/accounts/serializers/automations/gather_accounts.py b/apps/accounts/serializers/automations/gather_accounts.py deleted file mode 100644 index cbef21307..000000000 --- a/apps/accounts/serializers/automations/gather_accounts.py +++ /dev/null @@ -1,27 +0,0 @@ -# -*- coding: utf-8 -*- -# -from accounts.const import AutomationTypes -from accounts.models import GatherAccountsAutomation -from common.utils import get_logger - -from .base import BaseAutomationSerializer - -logger = get_logger(__file__) - -__all__ = [ - 'GatherAccountAutomationSerializer', -] - - -class GatherAccountAutomationSerializer(BaseAutomationSerializer): - class Meta: - model = GatherAccountsAutomation - read_only_fields = BaseAutomationSerializer.Meta.read_only_fields - fields = BaseAutomationSerializer.Meta.fields \ - + ['is_sync_account', 'recipients'] + read_only_fields - - extra_kwargs = BaseAutomationSerializer.Meta.extra_kwargs - - @property - def model_type(self): - return AutomationTypes.gather_accounts diff --git a/apps/accounts/serializers/automations/push_account.py b/apps/accounts/serializers/automations/push_account.py index b9982300b..5e571e345 100644 --- a/apps/accounts/serializers/automations/push_account.py +++ b/apps/accounts/serializers/automations/push_account.py @@ -2,7 +2,7 @@ from accounts.const import AutomationTypes from accounts.models import PushAccountAutomation from .change_secret import ( ChangeSecretAutomationSerializer, ChangeSecretUpdateAssetSerializer, - ChangeSecretUpdateNodeSerializer + ChangeSecretUpdateNodeSerializer, ChangeSecretRecordSerializer ) @@ -19,6 +19,10 @@ class PushAccountAutomationSerializer(ChangeSecretAutomationSerializer): return AutomationTypes.push_account +class PushSecretRecordSerializer(ChangeSecretRecordSerializer): + pass + + class PushAccountUpdateAssetSerializer(ChangeSecretUpdateAssetSerializer): class Meta: model = PushAccountAutomation diff --git a/apps/accounts/signal_handlers.py b/apps/accounts/signal_handlers.py index dd5758958..ee8bf86bc 100644 --- a/apps/accounts/signal_handlers.py +++ b/apps/accounts/signal_handlers.py @@ -1,7 +1,7 @@ from collections import defaultdict from django.db.models.signals import post_delete -from django.db.models.signals import pre_save, post_save +from django.db.models.signals import post_save from django.dispatch import receiver from django.utils.functional import LazyObject from django.utils.translation import gettext_noop @@ -21,18 +21,6 @@ from .tasks.push_account import push_accounts_to_assets_task logger = get_logger(__name__) -@receiver(pre_save, sender=Account) -def on_account_pre_save(sender, instance, **kwargs): - if getattr(instance, 'skip_history_when_saving', False): - return - - if instance.version == 0: - instance.version = 1 - else: - history_account = instance.history.first() - instance.version = history_account.version + 1 if history_account else 0 - - @merge_delay_run(ttl=5) def push_accounts_if_need(accounts=()): from .models import AccountTemplate diff --git a/apps/accounts/tasks/__init__.py b/apps/accounts/tasks/__init__.py index a20eba291..eade6bb27 100644 --- a/apps/accounts/tasks/__init__.py +++ b/apps/accounts/tasks/__init__.py @@ -1,7 +1,7 @@ from .automation import * -from .backup_account import * from .gather_accounts import * from .push_account import * from .remove_account import * +from .scan_account import * from .template import * from .verify_account import * diff --git a/apps/accounts/tasks/backup_account.py b/apps/accounts/tasks/backup_account.py index d7d708c86..e69de29bb 100644 --- a/apps/accounts/tasks/backup_account.py +++ b/apps/accounts/tasks/backup_account.py @@ -1,40 +0,0 @@ -# -*- coding: utf-8 -*- -# -from celery import shared_task -from django.utils.translation import gettext_lazy as _ - -from common.utils import get_object_or_none, get_logger -from orgs.utils import tmp_to_org, tmp_to_root_org - -logger = get_logger(__file__) - - -def task_activity_callback(self, pid, trigger, *args, **kwargs): - from accounts.models import AccountBackupAutomation - with tmp_to_root_org(): - plan = get_object_or_none(AccountBackupAutomation, pk=pid) - if not plan: - return - if not plan.latest_execution: - return - resource_ids = plan.latest_execution.backup_accounts - org_id = plan.org_id - return resource_ids, org_id - - -@shared_task( - verbose_name=_('Execute account backup plan'), - activity_callback=task_activity_callback, - description=_( - "When performing scheduled or manual account backups, this task is used" - ) -) -def execute_account_backup_task(pid, trigger, **kwargs): - from accounts.models import AccountBackupAutomation - with tmp_to_root_org(): - plan = get_object_or_none(AccountBackupAutomation, pk=pid) - if not plan: - logger.error("No account backup route plan found: {}".format(pid)) - return - with tmp_to_org(plan.org): - plan.execute(trigger) diff --git a/apps/accounts/tasks/gather_accounts.py b/apps/accounts/tasks/gather_accounts.py index 831a9fdf6..d39a9ab40 100644 --- a/apps/accounts/tasks/gather_accounts.py +++ b/apps/accounts/tasks/gather_accounts.py @@ -1,39 +1,6 @@ # ~*~ coding: utf-8 ~*~ -from celery import shared_task -from django.utils.translation import gettext_lazy as _ -from django.utils.translation import gettext_noop -from accounts.const import AutomationTypes -from accounts.tasks.common import quickstart_automation_by_snapshot -from assets.models import Node from common.utils import get_logger -from orgs.utils import org_aware_func -__all__ = ['gather_asset_accounts_task'] logger = get_logger(__name__) - -@org_aware_func("nodes") -def gather_asset_accounts_util(nodes, task_name): - from accounts.models import GatherAccountsAutomation - task_name = GatherAccountsAutomation.generate_unique_name(task_name) - - task_snapshot = { - 'nodes': [str(node.id) for node in nodes], - } - tp = AutomationTypes.verify_account - quickstart_automation_by_snapshot(task_name, tp, task_snapshot) - - -@shared_task( - queue="ansible", - verbose_name=_('Gather asset accounts'), - activity_callback=lambda self, node_ids, task_name=None, *args, **kwargs: (node_ids, None), - description=_("Unused") -) -def gather_asset_accounts_task(node_ids, task_name=None): - if task_name is None: - task_name = gettext_noop("Gather assets accounts") - - nodes = Node.objects.filter(id__in=node_ids) - gather_asset_accounts_util(nodes=nodes, task_name=task_name) diff --git a/apps/accounts/tasks/remove_account.py b/apps/accounts/tasks/remove_account.py index f5f1936f5..bbd7821c7 100644 --- a/apps/accounts/tasks/remove_account.py +++ b/apps/accounts/tasks/remove_account.py @@ -39,7 +39,7 @@ def remove_accounts_task(gather_account_ids): task_snapshot = { 'assets': [str(i.asset_id) for i in gather_accounts], - 'gather_accounts': [str(i.id) for i in gather_accounts], + 'accounts': [{'asset': str(i.asset_id), 'username': i.username} for i in gather_accounts], } tp = AutomationTypes.remove_account diff --git a/apps/accounts/tasks/scan_account.py b/apps/accounts/tasks/scan_account.py new file mode 100644 index 000000000..bc68e8671 --- /dev/null +++ b/apps/accounts/tasks/scan_account.py @@ -0,0 +1,3 @@ +from common.utils import get_logger + +logger = get_logger(__file__) diff --git a/apps/accounts/templates/accounts/backup_account_report.html b/apps/accounts/templates/accounts/backup_account_report.html new file mode 100644 index 000000000..3e7cb853f --- /dev/null +++ b/apps/accounts/templates/accounts/backup_account_report.html @@ -0,0 +1,202 @@ +{% load i18n %} +{% load static %} + + + +
+
+ Logo +
+ +
+

+ {% trans 'The following is a summary of account backup tasks, please review and handle them' %} +

+
+ +
+
+
+
+

+ {% trans 'Task name' %}: + {{ execution.automation.name }} +

+

+ {% trans 'Date start' %}: + {{ execution.date_start | date:"Y/m/d H:i:s" }} +

+

+ {% trans 'Date end' %}: + {{ execution.date_finished | date:"Y/m/d H:i:s" }} +

+

+ {% trans 'Time using' %}: + {{ execution.duration }}s +

+

+ {% trans 'Account count' %}: + {{ summary.total_accounts }}s +

+

+ {% trans 'Type count' %}: + {{ summary.total_types }}s +

+
+
+
+
+
+ + + + diff --git a/apps/accounts/templates/accounts/change_secret_report.html b/apps/accounts/templates/accounts/change_secret_report.html new file mode 100644 index 000000000..5f75a87b7 --- /dev/null +++ b/apps/accounts/templates/accounts/change_secret_report.html @@ -0,0 +1,314 @@ +{% load i18n %} +{% load static %} + + + +
+
+ Logo +
+ +
+

+ {% trans 'The following is a summary of account change secret tasks, please read and process' %} +

+
+ +
+
+
+
+

+ {% trans 'Task name' %}: + {{ execution.automation.name }} +

+

+ {% trans 'Date start' %}: + {{ execution.date_start | date:"Y/m/d H:i:s" }} +

+

+ {% trans 'Date end' %}: + {{ execution.date_finished | date:"Y/m/d H:i:s" }} +

+

+ {% trans 'Time using' %}: + {{ execution.duration }}s +

+

+ {% trans 'Assets count' %}: + {{ summary.total_assets }} +

+

+ {% trans 'Asset success count' %}: + {{ summary.ok_assets | default:0 }} +

+

+ {% trans 'Asset failed count' %}: + {{ summary.fail_assets | default:0 }} +

+

+ {% trans 'Asset not support count' %}: + {{ summary.error_assets | default:0 }} +

+
+
+
+ +
+
+
+

+ {% trans 'Success accounts' %}: + {{ summary.ok_accounts | default:0 }} +

+
+ {% if summary.ok_accounts %} + + + + + + + + + + {% for account in result.ok_accounts %} + + + + + + {% endfor %} + +
{% trans 'No.' %}{% trans 'Asset' %}{% trans 'Username' %}
{{ forloop.counter }}{{ account.asset }}{{ account.username }}
+ {% else %} +

{% trans 'No new accounts found' %}

+ {% endif %} +
+ +
+
+

+ {% trans 'Failed accounts' %}: + {{ summary.fail_accounts | default:0 }} +

+
+ + {% if summary.fail_accounts %} + + + + + + + + + + {% for account in result.fail_accounts %} + + + + + + {% endfor %} + +
{% trans 'No.' %}{% trans 'Asset' %}{% trans 'Username' %}
{{ forloop.counter }}{{ account.asset }}{{ account.username }}
+ {% else %} +

{% trans 'No new accounts found' %}

+ {% endif %} +
+
+
+
+ + diff --git a/apps/accounts/templates/accounts/check_account_report.html b/apps/accounts/templates/accounts/check_account_report.html new file mode 100644 index 000000000..1991e8a25 --- /dev/null +++ b/apps/accounts/templates/accounts/check_account_report.html @@ -0,0 +1,293 @@ +{% load i18n %} +{% load static %} + + + +
+
+ Logo +
+ +
+

+ {% trans 'The following is a summary of the account check tasks. Please review and handle them' %} +

+
+ +
+
+
+
+

+ {% trans 'Task name' %}: + {{ execution.automation.name }} +

+

+ {% trans 'Date start' %}: + {{ execution.date_start | date:"Y/m/d H:i:s" }} +

+

+ {% trans 'Date end' %}: + {{ execution.date_finished | date:"Y/m/d H:i:s" }} +

+

+ {% trans 'Time using' %}: + {{ execution.duration }}s +

+

+ {% trans 'Account count' %}: + {{ summary.accounts }} +

+

+ {% trans 'Ok count' %}: + {{ summary.ok }} +

+

+ {% trans 'No password count' %}: + {{ summary.no_secret }} +

+

+ {% trans 'Asset success count' %}: + {{ summary.ok_assets | default:0 }} +

+

+ {% trans 'Asset failed count' %}: + {{ summary.fail_assets | default:0 }} +

+

+ {% trans 'Asset not support count' %}: + {{ summary.error_assets | default:0 }} +

+
+
+
+ +
+
+
+

+ {% trans 'Week password' %}: + {{ summary.weak_password | default:0 }} +

+
+ {% if summary.ok_accounts %} + + + + + + + + + + + {% for account in result.weak_password %} + + + + + + + {% endfor %} + +
{% trans 'No.' %}{% trans 'Asset' %}{% trans 'Username' %}{% trans 'Result' %}
{{ forloop.counter }}{{ account.asset }}{{ account.username }}{% trans 'Week password' %}
+ {% else %} +

{% trans 'No weak password' %}

+ {% endif %} +
+
+
+
+ + + diff --git a/apps/accounts/templates/accounts/gather_account_report.html b/apps/accounts/templates/accounts/gather_account_report.html new file mode 100644 index 000000000..3d110cea8 --- /dev/null +++ b/apps/accounts/templates/accounts/gather_account_report.html @@ -0,0 +1,315 @@ +{% load i18n %} +{% load static %} + + + + +
+
+ Logo +
+ +
+

+ {% trans 'The following is a summary of the account check tasks. Please review and handle them' %} +

+
+ +
+
+
+
+

+ {% trans 'Task name' %}: + {{ execution.automation.name }} +

+

+ {% trans 'Date start' %}: + {{ execution.date_start | date:"Y/m/d H:i:s" }} +

+

+ {% trans 'Date end' %}: + {{ execution.date_finished | date:"Y/m/d H:i:s" }} +

+

+ {% trans 'Time using' %}: + {{ execution.duration }}s +

+

+ {% trans 'Assets count' %}: + {{ summary.total_assets }} +

+

+ {% trans 'Asset success count' %}: + {{ summary.ok_assets | default:0 }} +

+

+ {% trans 'Asset failed count' %}: + {{ summary.fail_assets | default:0 }} +

+

+ {% trans 'Asset not support count' %}: + {{ summary.error_assets | default:0 }} +

+
+
+
+ +
+
+
+

+ {% trans 'New found accounts' %}: + {{ summary.new_accounts | default:0 }} +

+
+ {% if summary.new_accounts %} + + + + + + + + + + {% for account in result.new_accounts %} + + + + + + {% endfor %} + +
{% trans 'No.' %}{% trans 'Asset' %}{% trans 'Username' %}
{{ forloop.counter }}{{ account.asset }}{{ account.username }}
+ {% else %} +

{% trans 'No new accounts found' %}

+ {% endif %} +
+ +
+
+

+ {% trans 'Lost accounts' %}: + {{ summary.lost_accounts | default:0 }} +

+
+ + {% if summary.lost_accounts %} + + + + + + + + + + {% for account in result.lost_accounts %} + + + + + + {% endfor %} + +
{% trans 'No.' %}{% trans 'Asset' %}{% trans 'Username' %}
{{ forloop.counter }}{{ account.asset }}{{ account.username }}
+ {% else %} +

{% trans 'No new accounts found' %}

+ {% endif %} +
+
+
+
+ + diff --git a/apps/accounts/templates/accounts/push_account_report.html b/apps/accounts/templates/accounts/push_account_report.html new file mode 100644 index 000000000..a968b1616 --- /dev/null +++ b/apps/accounts/templates/accounts/push_account_report.html @@ -0,0 +1,314 @@ +{% load i18n %} +{% load static %} + + + +
+
+ Logo +
+ +
+

+ {% trans 'The following is a summary of account push tasks, please read and process' %} +

+
+ +
+
+
+
+

+ {% trans 'Task name' %}: + {{ execution.automation.name }} +

+

+ {% trans 'Date start' %}: + {{ execution.date_start | date:"Y/m/d H:i:s" }} +

+

+ {% trans 'Date end' %}: + {{ execution.date_finished | date:"Y/m/d H:i:s" }} +

+

+ {% trans 'Time using' %}: + {{ execution.duration }}s +

+

+ {% trans 'Assets count' %}: + {{ summary.total_assets }} +

+

+ {% trans 'Asset success count' %}: + {{ summary.ok_assets | default:0 }} +

+

+ {% trans 'Asset failed count' %}: + {{ summary.fail_assets | default:0 }} +

+

+ {% trans 'Asset not support count' %}: + {{ summary.error_assets | default:0 }} +

+
+
+
+ +
+
+
+

+ {% trans 'Success accounts' %}: + {{ summary.ok_accounts | default:0 }} +

+
+ {% if summary.ok_accounts %} + + + + + + + + + + {% for account in result.ok_accounts %} + + + + + + {% endfor %} + +
{% trans 'No.' %}{% trans 'Asset' %}{% trans 'Username' %}
{{ forloop.counter }}{{ account.asset }}{{ account.username }}
+ {% else %} +

{% trans 'No new accounts found' %}

+ {% endif %} +
+ +
+
+

+ {% trans 'Failed accounts' %}: + {{ summary.fail_accounts | default:0 }} +

+
+ + {% if summary.fail_accounts %} + + + + + + + + + + {% for account in result.fail_accounts %} + + + + + + {% endfor %} + +
{% trans 'No.' %}{% trans 'Asset' %}{% trans 'Username' %}
{{ forloop.counter }}{{ account.asset }}{{ account.username }}
+ {% else %} +

{% trans 'No new accounts found' %}

+ {% endif %} +
+
+
+
+ + diff --git a/apps/accounts/urls.py b/apps/accounts/urls.py index 12d04274e..8f567a0d3 100644 --- a/apps/accounts/urls.py +++ b/apps/accounts/urls.py @@ -14,16 +14,21 @@ router.register(r'gathered-accounts', api.GatheredAccountViewSet, 'gathered-acco router.register(r'account-secrets', api.AccountSecretsViewSet, 'account-secret') router.register(r'account-templates', api.AccountTemplateViewSet, 'account-template') router.register(r'account-template-secrets', api.AccountTemplateSecretsViewSet, 'account-template-secret') -router.register(r'account-backup-plans', api.AccountBackupPlanViewSet, 'account-backup') -router.register(r'account-backup-plan-executions', api.AccountBackupPlanExecutionViewSet, 'account-backup-execution') +router.register(r'account-backup-plans', api.BackupAccountViewSet, 'account-backup') +router.register(r'account-backup-plan-executions', api.BackupAccountExecutionViewSet, 'account-backup-execution') router.register(r'change-secret-automations', api.ChangeSecretAutomationViewSet, 'change-secret-automation') router.register(r'change-secret-executions', api.ChangSecretExecutionViewSet, 'change-secret-execution') router.register(r'change-secret-records', api.ChangeSecretRecordViewSet, 'change-secret-record') -router.register(r'gather-account-automations', api.GatherAccountsAutomationViewSet, 'gather-account-automation') -router.register(r'gather-account-executions', api.GatherAccountsExecutionViewSet, 'gather-account-execution') +router.register(r'gather-account-automations', api.DiscoverAccountsAutomationViewSet, 'gather-account-automation') +router.register(r'gather-account-executions', api.DiscoverAccountsExecutionViewSet, 'gather-account-execution') router.register(r'push-account-automations', api.PushAccountAutomationViewSet, 'push-account-automation') router.register(r'push-account-executions', api.PushAccountExecutionViewSet, 'push-account-execution') router.register(r'push-account-records', api.PushAccountRecordViewSet, 'push-account-record') +router.register(r'check-account-automations', api.CheckAccountAutomationViewSet, 'check-account-automation') +router.register(r'check-account-executions', api.CheckAccountExecutionViewSet, 'check-account-execution') +router.register(r'account-check-engines', api.CheckAccountEngineViewSet, 'account-check-engine') +router.register(r'account-risks', api.AccountRiskViewSet, 'account-risks') +router.register(r'integration-applications', api.IntegrationApplicationViewSet, 'integration-apps') urlpatterns = [ path('accounts/bulk/', api.AssetAccountBulkCreateApi.as_view(), name='account-bulk-create'), @@ -44,6 +49,8 @@ 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('pam-dashboard/', api.PamDashboardApi.as_view(), name='pam-dashboard'), + path('change-secret-dashboard/', api.ChangeSecretDashboardApi.as_view(), name='change-secret-dashboard'), ] urlpatterns += router.urls diff --git a/apps/accounts/utils.py b/apps/accounts/utils.py index 9df67daf6..9e2b84e74 100644 --- a/apps/accounts/utils.py +++ b/apps/accounts/utils.py @@ -4,6 +4,7 @@ from django.utils.translation import gettext_lazy as _ from rest_framework import serializers from accounts.const import SecretType, DEFAULT_PASSWORD_RULES + from common.utils import ssh_key_gen, random_string from common.utils import validate_ssh_private_key, parse_ssh_private_key_str @@ -26,12 +27,12 @@ class SecretGenerator: rules = copy.deepcopy(DEFAULT_PASSWORD_RULES) rules.update(password_rules) rules = { - 'length': rules['length'], - 'lower': rules['lowercase'], - 'upper': rules['uppercase'], - 'digit': rules['digit'], - 'special_char': rules['symbol'], - 'exclude_chars': rules.get('exclude_symbols', ''), + "length": rules["length"], + "lower": rules["lowercase"], + "upper": rules["uppercase"], + "digit": rules["digit"], + "special_char": rules["symbol"], + "exclude_chars": rules.get("exclude_symbols", ""), } return random_string(**rules) @@ -46,10 +47,12 @@ class SecretGenerator: def validate_password_for_ansible(password): - """ 校验 Ansible 不支持的特殊字符 """ - if password.startswith('{{') and password.endswith('}}'): + """校验 Ansible 不支持的特殊字符""" + if password.startswith("{{") and password.endswith("}}"): raise serializers.ValidationError( - _('If the password starts with {{` and ends with }} `, then the password is not allowed.') + _( + "If the password starts with {{` and ends with }} `, then the password is not allowed." + ) ) diff --git a/apps/assets/api/asset/asset.py b/apps/assets/api/asset/asset.py index 1b93fa44c..0a44610ce 100644 --- a/apps/assets/api/asset/asset.py +++ b/apps/assets/api/asset/asset.py @@ -129,7 +129,8 @@ class AssetViewSet(SuggestionMixin, OrgBulkModelViewSet): def get_queryset(self): queryset = super().get_queryset() - if queryset.model is not Asset: + if queryset.model.__name__ != 'Asset': + print("get query prefetch") queryset = queryset.select_related('asset_ptr') return queryset diff --git a/apps/assets/api/favorite_asset.py b/apps/assets/api/favorite_asset.py index cf85d96f2..ae1c624a7 100644 --- a/apps/assets/api/favorite_asset.py +++ b/apps/assets/api/favorite_asset.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -from rest_framework_bulk import BulkModelViewSet +from rest_framework_bulk.generics import BulkModelViewSet from common.permissions import IsValidUser from orgs.utils import tmp_to_root_org diff --git a/apps/assets/api/tree.py b/apps/assets/api/tree.py index 32f6aed27..b3eedaa42 100644 --- a/apps/assets/api/tree.py +++ b/apps/assets/api/tree.py @@ -146,7 +146,8 @@ class NodeChildrenAsTreeApi(SerializeToTreeNodeMixin, NodeChildrenApi): def list(self, request, *args, **kwargs): nodes = self.filter_queryset(self.get_queryset()).order_by('value') - nodes = self.serialize_nodes(nodes, with_asset_amount=True) + with_asset_amount = request.query_params.get('asset_amount', '1') == '1' + nodes = self.serialize_nodes(nodes, with_asset_amount=with_asset_amount) assets = self.filter_queryset_for_assets(self.get_queryset_for_assets()) node_key = self.instance.key if self.instance else None assets = self.serialize_assets(assets, node_key=node_key) diff --git a/apps/assets/automations/base/manager.py b/apps/assets/automations/base/manager.py index da3e2353f..a8d630f57 100644 --- a/apps/assets/automations/base/manager.py +++ b/apps/assets/automations/base/manager.py @@ -1,16 +1,24 @@ import hashlib import json +import logging import os import shutil +import time +from collections import defaultdict from socket import gethostname import yaml from django.conf import settings +from django.template.loader import render_to_string from django.utils import timezone from django.utils.translation import gettext as _ +from premailer import transform from sshtunnel import SSHTunnelForwarder from assets.automations.methods import platform_automation_methods +from common.const import Status +from common.db.utils import safe_db_connection +from common.tasks import send_mail_async from common.utils import get_logger, lazyproperty, is_openssh_format_key, ssh_pubkey_gen from ops.ansible import JMSInventory, DefaultCallback, SuperPlaybookRunner from ops.ansible.interface import interface @@ -24,46 +32,49 @@ class SSHTunnelManager: @staticmethod def file_to_json(path): - with open(path, 'r') as f: + with open(path, "r") as f: d = json.load(f) return d @staticmethod def json_to_file(path, data): - with open(path, 'w') as f: + with open(path, "w") as f: json.dump(data, f, indent=4, sort_keys=True) def local_gateway_prepare(self, runner): info = self.file_to_json(runner.inventory) servers, not_valid = [], [] - for k, host in info['all']['hosts'].items(): - jms_asset, jms_gateway = host.get('jms_asset'), host.get('jms_gateway') + for k, host in info["all"]["hosts"].items(): + jms_asset, jms_gateway = host.get("jms_asset"), host.get("jms_gateway") if not jms_gateway: continue try: server = SSHTunnelForwarder( - (jms_gateway['address'], jms_gateway['port']), - ssh_username=jms_gateway['username'], - ssh_password=jms_gateway['secret'], - ssh_pkey=jms_gateway['private_key_path'], - remote_bind_address=(jms_asset['address'], jms_asset['port']) + (jms_gateway["address"], jms_gateway["port"]), + ssh_username=jms_gateway["username"], + ssh_password=jms_gateway["secret"], + ssh_pkey=jms_gateway["private_key_path"], + remote_bind_address=(jms_asset["address"], jms_asset["port"]), ) server.start() except Exception as e: - err_msg = 'Gateway is not active: %s' % jms_asset.get('name', '') - print(f'\033[31m {err_msg} 原因: {e} \033[0m\n') + err_msg = "Gateway is not active: %s" % jms_asset.get("name", "") + print(f"\033[31m {err_msg} 原因: {e} \033[0m\n") not_valid.append(k) else: local_bind_port = server.local_bind_port - host['ansible_host'] = jms_asset['address'] = host[ - 'login_host'] = interface.get_gateway_proxy_host() - host['ansible_port'] = jms_asset['port'] = host['login_port'] = local_bind_port + host["ansible_host"] = jms_asset["address"] = host["login_host"] = ( + interface.get_gateway_proxy_host() + ) + host["ansible_port"] = jms_asset["port"] = host["login_port"] = ( + local_bind_port + ) servers.append(server) # 网域不可连接的,就不继续执行此资源的后续任务了 for a in set(not_valid): - info['all']['hosts'].pop(a) + info["all"]["hosts"].pop(a) self.json_to_file(runner.inventory, info) self.gateway_servers[runner.id] = servers @@ -81,30 +92,127 @@ class PlaybookCallback(DefaultCallback): super().playbook_on_stats(event_data, **kwargs) -class BasePlaybookManager: - bulk_size = 100 - ansible_account_policy = 'privileged_first' - ansible_account_prefer = 'root,Administrator' - +class BaseManager: def __init__(self, execution): self.execution = execution + self.time_start = time.time() + self.summary = defaultdict(int) + self.result = defaultdict(list) + self.duration = 0 + self.status = Status.success + + def get_assets_group_by_platform(self): + return self.execution.all_assets_group_by_platform() + + def pre_run(self): + self.execution.date_start = timezone.now() + self.execution.status = Status.running + self.execution.save(update_fields=["date_start", "status"]) + + def update_execution(self): + self.duration = int(time.time() - self.time_start) + self.execution.date_finished = timezone.now() + self.execution.duration = self.duration + self.execution.summary = self.summary + self.execution.result = self.result + self.execution.status = self.status + + with safe_db_connection(): + self.execution.save() + + def print_summary(self): + content = "\nSummery: \n" + for k, v in self.summary.items(): + content += f"\t - {k}: {v}\n" + content += "\t - Using: {}s\n".format(self.duration) + print(content) + + def get_report_template(self): + raise NotImplementedError + + def get_report_subject(self): + return f"Automation {self.execution.id} finished" + + def get_report_context(self): + return { + "execution": self.execution, + "summary": self.execution.summary, + "result": self.execution.result, + } + + def send_report_if_need(self): + recipients = self.execution.recipients + if not recipients: + return + print("Send report to: ", ",".join([str(u) for u in recipients])) + + report = self.gen_report() + report = transform(report) + subject = self.get_report_subject() + emails = [r.email for r in recipients if r.email] + send_mail_async(subject, report, emails, html_message=report) + + def gen_report(self): + template_path = self.get_report_template() + context = self.get_report_context() + data = render_to_string(template_path, context) + return data + + def post_run(self): + self.update_execution() + self.print_summary() + self.send_report_if_need() + + def run(self, *args, **kwargs): + self.pre_run() + try: + self.do_run(*args, **kwargs) + except Exception as e: + logging.exception(e) + self.status = 'error' + finally: + self.post_run() + + def do_run(self, *args, **kwargs): + raise NotImplementedError + + @staticmethod + def json_dumps(data): + return json.dumps(data, indent=4, sort_keys=True) + + +class PlaybookPrepareMixin: + bulk_size = 100 + ansible_account_policy = "privileged_first" + ansible_account_prefer = "root,Administrator" + + summary: dict + result: dict + params: dict + execution = None + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # example: {'gather_fact_windows': {'id': 'gather_fact_windows', 'name': '', 'method': 'gather_fact', ...} } self.method_id_meta_mapper = { - method['id']: method + method["id"]: method for method in self.platform_automation_methods - if method['method'] == self.__class__.method_type() + if method["method"] == self.__class__.method_type() } # 根据执行方式就行分组, 不同资产的改密、推送等操作可能会使用不同的执行方式 # 然后根据执行方式分组, 再根据 bulk_size 分组, 生成不同的 playbook self.playbooks = [] - params = self.execution.snapshot.get('params') - self.params = params or {} + + @classmethod + def method_type(cls): + raise NotImplementedError def get_params(self, automation, method_type): - method_attr = '{}_method'.format(method_type) - method_params = '{}_params'.format(method_type) + method_attr = "{}_method".format(method_type) + method_params = "{}_params".format(method_type) method_id = getattr(automation, method_attr) automation_params = getattr(automation, method_params) - serializer = self.method_id_meta_mapper[method_id]['params_serializer'] + serializer = self.method_id_meta_mapper[method_id]["params_serializer"] if serializer is None: return {} @@ -119,36 +227,30 @@ class BasePlaybookManager: def platform_automation_methods(self): return platform_automation_methods - @classmethod - def method_type(cls): - raise NotImplementedError - - def get_assets_group_by_platform(self): - return self.execution.all_assets_group_by_platform() - def prepare_runtime_dir(self): ansible_dir = settings.ANSIBLE_DIR - task_name = self.execution.snapshot['name'] - dir_name = '{}_{}'.format(task_name.replace(' ', '_'), self.execution.id) + task_name = self.execution.snapshot["name"] + dir_name = "{}_{}".format(task_name.replace(" ", "_"), self.execution.id) path = os.path.join( - ansible_dir, 'automations', self.execution.snapshot['type'], - dir_name, timezone.now().strftime('%Y%m%d_%H%M%S') + ansible_dir, + "automations", + self.execution.snapshot["type"], + dir_name, + timezone.now().strftime("%Y%m%d_%H%M%S"), ) if not os.path.exists(path): os.makedirs(path, exist_ok=True, mode=0o755) return path - @lazyproperty - def runtime_dir(self): - path = self.prepare_runtime_dir() - if settings.DEBUG_DEV: - msg = 'Ansible runtime dir: {}'.format(path) - print(msg) - return path + def host_callback(self, host, automation=None, **kwargs): + method_type = self.__class__.method_type() + host = self.convert_cert_to_file(host, kwargs.get("path_dir")) + host["params"] = self.get_params(automation, method_type) + return host @staticmethod def write_cert_to_file(filename, content): - with open(filename, 'w') as f: + with open(filename, "w") as f: f.write(content) return filename @@ -156,40 +258,19 @@ class BasePlaybookManager: if not path_dir: return host - specific = host.get('jms_asset', {}).get('secret_info', {}) - cert_fields = ('ca_cert', 'client_key', 'client_cert') + specific = host.get("jms_asset", {}).get("secret_info", {}) + cert_fields = ("ca_cert", "client_key", "client_cert") filtered = list(filter(lambda x: specific.get(x), cert_fields)) if not filtered: return host - cert_dir = os.path.join(path_dir, 'certs') + cert_dir = os.path.join(path_dir, "certs") if not os.path.exists(cert_dir): os.makedirs(cert_dir, 0o700, True) for f in filtered: - result = self.write_cert_to_file( - os.path.join(cert_dir, f), specific.get(f) - ) - os.chmod(result, 0o600) - host['jms_asset']['secret_info'][f] = result - return host - - def host_callback(self, host, automation=None, **kwargs): - method_type = self.__class__.method_type() - enabled_attr = '{}_enabled'.format(method_type) - method_attr = '{}_method'.format(method_type) - - method_enabled = automation and \ - getattr(automation, enabled_attr) and \ - getattr(automation, method_attr) and \ - getattr(automation, method_attr) in self.method_id_meta_mapper - - if not method_enabled: - host['error'] = _('{} disabled'.format(self.__class__.method_type())) - return host - - host = self.convert_cert_to_file(host, kwargs.get('path_dir')) - host['params'] = self.get_params(automation, method_type) + result = self.write_cert_to_file(os.path.join(cert_dir, f), specific.get(f)) + host["jms_asset"]["secret_info"][f] = result return host @staticmethod @@ -198,16 +279,16 @@ class BasePlaybookManager: @staticmethod def generate_private_key_path(secret, path_dir): - key_name = '.' + hashlib.md5(secret.encode('utf-8')).hexdigest() + key_name = "." + hashlib.md5(secret.encode("utf-8")).hexdigest() key_path = os.path.join(path_dir, key_name) if not os.path.exists(key_path): # https://github.com/ansible/ansible-runner/issues/544 # ssh requires OpenSSH format keys to have a full ending newline. # It does not require this for old-style PEM keys. - with open(key_path, 'w') as f: + with open(key_path, "w") as f: f.write(secret) - if is_openssh_format_key(secret.encode('utf-8')): + if is_openssh_format_key(secret.encode("utf-8")): f.write("\n") os.chmod(key_path, 0o400) return key_path @@ -223,129 +304,240 @@ class BasePlaybookManager: ) inventory.write_to_file(inventory_path) + @lazyproperty + def runtime_dir(self): + path = self.prepare_runtime_dir() + if settings.DEBUG_DEV: + msg = "Ansible runtime dir: {}".format(path) + print(msg) + return path + @staticmethod def generate_playbook(method, sub_playbook_dir): - method_playbook_dir_path = method['dir'] - sub_playbook_path = os.path.join(sub_playbook_dir, 'project', 'main.yml') + method_playbook_dir_path = method["dir"] + sub_playbook_path = os.path.join(sub_playbook_dir, "project", "main.yml") shutil.copytree(method_playbook_dir_path, os.path.dirname(sub_playbook_path)) - with open(sub_playbook_path, 'r') as f: + with open(sub_playbook_path, "r") as f: plays = yaml.safe_load(f) for play in plays: - play['hosts'] = 'all' + play["hosts"] = "all" - with open(sub_playbook_path, 'w') as f: + with open(sub_playbook_path, "w") as f: yaml.safe_dump(plays, f) return sub_playbook_path + def check_automation_enabled(self, platform, assets): + if not platform.automation or not platform.automation.ansible_enabled: + print(_(" - Platform {} ansible disabled").format(platform.name)) + self.on_assets_not_ansible_enabled(assets) + + automation = platform.automation + + method_type = self.__class__.method_type() + enabled_attr = "{}_enabled".format(method_type) + method_attr = "{}_method".format(method_type) + + method_enabled = ( + automation + and getattr(automation, enabled_attr) + and getattr(automation, method_attr) + and getattr(automation, method_attr) in self.method_id_meta_mapper + ) + + if not method_enabled: + self.on_assets_not_method_enabled(assets, method_type) + return False + return True + + def on_assets_not_ansible_enabled(self, assets): + self.summary["error_assets"] += len(assets) + self.result["error_assets"].extend([str(asset) for asset in assets]) + for asset in assets: + print("\t{}".format(asset)) + + def on_assets_not_method_enabled(self, assets, method_type): + self.summary["error_assets"] += len(assets) + self.result["error_assets"].extend([str(asset) for asset in assets]) + for asset in assets: + print("\t{}".format(asset)) + + def on_playbook_not_found(self, assets): + print("Playbook generate failed") + + +class BasePlaybookManager(PlaybookPrepareMixin, BaseManager): + bulk_size = 100 + ansible_account_policy = "privileged_first" + ansible_account_prefer = "root,Administrator" + + def __init__(self, execution): + super().__init__(execution) + self.params = execution.snapshot.get("params", {}) + self.host_success_callbacks = [] + + def get_assets_group_by_platform(self): + return self.execution.all_assets_group_by_platform() + + @classmethod + def method_type(cls): + raise NotImplementedError + + def get_runners_by_platform(self, platform, _assets, _index): + sub_dir = "{}_{}".format(platform.name, _index) + playbook_dir = os.path.join(self.runtime_dir, sub_dir) + inventory_path = os.path.join(self.runtime_dir, sub_dir, "hosts.json") + + method_id = getattr( + platform.automation, + "{}_method".format(self.__class__.method_type()), + ) + method = self.method_id_meta_mapper.get(method_id) + + protocol = method.get("protocol") + self.generate_inventory(_assets, inventory_path, protocol) + playbook_path = self.generate_playbook(method, playbook_dir) + + if not playbook_path: + self.on_playbook_not_found(_assets) + return None, None + + runner = SuperPlaybookRunner( + inventory_path, + playbook_path, + self.runtime_dir, + callback=PlaybookCallback(), + ) + return runner, inventory_path + def get_runners(self): assets_group_by_platform = self.get_assets_group_by_platform() if settings.DEBUG_DEV: - msg = 'Assets group by platform: {}'.format(dict(assets_group_by_platform)) + msg = "Assets group by platform: {}".format(dict(assets_group_by_platform)) print(msg) + runners = [] for platform, assets in assets_group_by_platform.items(): + self.summary["total_assets"] += len(assets) if not assets: + print("No assets for platform: {}".format(platform.name)) continue - if not platform.automation or not platform.automation.ansible_enabled: - print(_(" - Platform {} ansible disabled").format(platform.name)) - continue - assets_bulked = [assets[i:i + self.bulk_size] for i in range(0, len(assets), self.bulk_size)] + if not self.check_automation_enabled(platform, assets): + print("Platform {} ansible disabled".format(platform.name)) + continue + + # 避免一个任务太大,分批执行 + assets_bulked = [ + assets[i: i + self.bulk_size] + for i in range(0, len(assets), self.bulk_size) + ] for i, _assets in enumerate(assets_bulked, start=1): - sub_dir = '{}_{}'.format(platform.name, i) - playbook_dir = os.path.join(self.runtime_dir, sub_dir) - inventory_path = os.path.join(self.runtime_dir, sub_dir, 'hosts.json') - - method_id = getattr(platform.automation, '{}_method'.format(self.__class__.method_type())) - method = self.method_id_meta_mapper.get(method_id) - - if not method: - logger.error("Method not found: {}".format(method_id)) - continue - protocol = method.get('protocol') - self.generate_inventory(_assets, inventory_path, protocol) - playbook_path = self.generate_playbook(method, playbook_dir) - if not playbook_path: - continue - - runer = SuperPlaybookRunner( - inventory_path, - playbook_path, - self.runtime_dir, - callback=PlaybookCallback(), + runner, inventory_path = self.get_runners_by_platform( + platform, _assets, i ) - with open(inventory_path, 'r') as f: + if not runner or not inventory_path: + continue + + with open(inventory_path, "r") as f: inventory_data = json.load(f) - if not inventory_data['all'].get('hosts'): + if not inventory_data["all"].get("hosts"): continue - runners.append(runer) + runners.append( + ( + runner, + { + "assets": _assets, + "inventory": inventory_path, + "platform": platform, + }, + ) + ) return runners def on_host_success(self, host, result): - pass + self.summary["ok_assets"] += 1 + self.result["ok_assets"].append(host) + + for cb in self.host_success_callbacks: + cb(host, result) def on_host_error(self, host, error, result): - if settings.DEBUG_DEV: - print('host error: {} -> {}'.format(host, error)) + self.summary["fail_assets"] += 1 + self.result["fail_assets"].append((host, str(error))) + print(f"\033[31m {host} error: {error} \033[0m\n") + + def _on_host_success(self, host, result, hosts): + self.on_host_success(host, result.get("ok", "")) + + def _on_host_error(self, host, result, hosts): + error = hosts.get(host, "") + detail = result.get("failures", "") or result.get("dark", "") + self.on_host_error(host, error, detail) + + def post_run(self): + if self.summary['fail_assets']: + self.status = 'failed' + super().post_run() def on_runner_success(self, runner, cb): summary = cb.summary for state, hosts in summary.items(): + # 错误行为为,host 是 dict, ok 时是 list + + if state == "ok": + handler = self._on_host_success + elif state == "skipped": + continue + else: + handler = self._on_host_error + for host in hosts: result = cb.host_results.get(host) - if state == 'ok': - self.on_host_success(host, result.get('ok', '')) - elif state == 'skipped': - pass - else: - error = hosts.get(host) - self.on_host_error( - host, error, - result.get('failures', '') - or result.get('dark', '') - ) + handler(host, result, hosts) - def on_runner_failed(self, runner, e): + def on_runner_failed(self, runner, e, assets=None, **kwargs): + self.summary["fail_assets"] += len(assets) + self.result["fail_assets"].extend( + [(str(asset), str("e")[:10]) for asset in assets] + ) print("Runner failed: {} {}".format(e, self)) - @staticmethod - def json_dumps(data): - return json.dumps(data, indent=4, sort_keys=True) - def delete_runtime_dir(self): if settings.DEBUG_DEV: return shutil.rmtree(self.runtime_dir, ignore_errors=True) - def run(self, *args, **kwargs): + def do_run(self, *args, **kwargs): print(_(">>> Task preparation phase"), end="\n") runners = self.get_runners() if len(runners) > 1: - print(_(">>> Executing tasks in batches, total {runner_count}").format(runner_count=len(runners))) + print( + _(">>> Executing tasks in batches, total {runner_count}").format( + runner_count=len(runners) + ) + ) elif len(runners) == 1: print(_(">>> Start executing tasks")) else: print(_(">>> No tasks need to be executed"), end="\n") - self.execution.date_start = timezone.now() - for i, runner in enumerate(runners, start=1): + for i, runner_info in enumerate(runners, start=1): if len(runners) > 1: print(_(">>> Begin executing batch {index} of tasks").format(index=i)) + + runner, info = runner_info ssh_tunnel = SSHTunnelManager() ssh_tunnel.local_gateway_prepare(runner) + try: kwargs.update({"clean_workspace": False}) cb = runner.run(**kwargs) self.on_runner_success(runner, cb) except Exception as e: - self.on_runner_failed(runner, e) + self.on_runner_failed(runner, e, **info) finally: ssh_tunnel.local_gateway_clean(runner) - print('\n') - self.execution.status = 'success' - self.execution.date_finished = timezone.now() - self.execution.save() - self.delete_runtime_dir() + print("\n") diff --git a/apps/assets/automations/endpoint.py b/apps/assets/automations/endpoint.py index 99feebc49..c29efbd48 100644 --- a/apps/assets/automations/endpoint.py +++ b/apps/assets/automations/endpoint.py @@ -1,6 +1,6 @@ +from .gather_facts.manager import GatherFactsManager from .ping.manager import PingManager from .ping_gateway.manager import PingGatewayManager -from .gather_facts.manager import GatherFactsManager from ..const import AutomationTypes @@ -17,3 +17,6 @@ class ExecutionManager: def run(self, *args, **kwargs): return self._runner.run(*args, **kwargs) + + def __getattr__(self, item): + return getattr(self._runner, item) diff --git a/apps/assets/automations/gather_facts/database/mongodb/main.yml b/apps/assets/automations/gather_facts/database/mongodb/main.yml index 48bba74d1..4a62567df 100644 --- a/apps/assets/automations/gather_facts/database/mongodb/main.yml +++ b/apps/assets/automations/gather_facts/database/mongodb/main.yml @@ -1,7 +1,7 @@ - hosts: mongodb gather_facts: no vars: - ansible_python_interpreter: /opt/py3/bin/python + ansible_python_interpreter: "{{ local_python_interpreter }}" tasks: - name: Get info diff --git a/apps/assets/automations/gather_facts/database/mysql/main.yml b/apps/assets/automations/gather_facts/database/mysql/main.yml index ac8c27ac2..4301abec0 100644 --- a/apps/assets/automations/gather_facts/database/mysql/main.yml +++ b/apps/assets/automations/gather_facts/database/mysql/main.yml @@ -1,7 +1,7 @@ - hosts: mysql gather_facts: no vars: - ansible_python_interpreter: /opt/py3/bin/python + ansible_python_interpreter: "{{ local_python_interpreter }}" check_ssl: "{{ jms_asset.spec_info.use_ssl and not jms_asset.spec_info.allow_invalid_cert }}" ca_cert: "{{ jms_asset.secret_info.ca_cert | default('') }}" ssl_cert: "{{ jms_asset.secret_info.client_cert | default('') }}" diff --git a/apps/assets/automations/gather_facts/database/oracle/main.yml b/apps/assets/automations/gather_facts/database/oracle/main.yml index ad89e7b7a..fa3e0ff99 100644 --- a/apps/assets/automations/gather_facts/database/oracle/main.yml +++ b/apps/assets/automations/gather_facts/database/oracle/main.yml @@ -1,7 +1,7 @@ - hosts: oracle gather_facts: no vars: - ansible_python_interpreter: /opt/py3/bin/python + ansible_python_interpreter: "{{ local_python_interpreter }}" tasks: - name: Get info diff --git a/apps/assets/automations/gather_facts/database/postgresql/main.yml b/apps/assets/automations/gather_facts/database/postgresql/main.yml index e730f8f85..66d67ef10 100644 --- a/apps/assets/automations/gather_facts/database/postgresql/main.yml +++ b/apps/assets/automations/gather_facts/database/postgresql/main.yml @@ -1,7 +1,7 @@ - hosts: postgresql gather_facts: no vars: - ansible_python_interpreter: /opt/py3/bin/python + ansible_python_interpreter: "{{ local_python_interpreter }}" check_ssl: "{{ jms_asset.spec_info.use_ssl }}" ca_cert: "{{ jms_asset.secret_info.ca_cert | default('') }}" ssl_cert: "{{ jms_asset.secret_info.client_cert | default('') }}" diff --git a/apps/assets/automations/ping/custom/ssh/main.yml b/apps/assets/automations/ping/custom/ssh/main.yml index 89b92bcaa..de651cac3 100644 --- a/apps/assets/automations/ping/custom/ssh/main.yml +++ b/apps/assets/automations/ping/custom/ssh/main.yml @@ -21,4 +21,5 @@ become_private_key_path: "{{ jms_custom_become_private_key_path | default(None) }}" old_ssh_version: "{{ jms_asset.old_ssh_version | default(False) }}" gateway_args: "{{ jms_asset.ansible_ssh_common_args | default(None) }}" + recv_timeout: "{{ params.recv_timeout | default(30) }}" diff --git a/apps/assets/automations/ping/database/mongodb/main.yml b/apps/assets/automations/ping/database/mongodb/main.yml index 5c3f6e0af..40e4a7d49 100644 --- a/apps/assets/automations/ping/database/mongodb/main.yml +++ b/apps/assets/automations/ping/database/mongodb/main.yml @@ -1,7 +1,7 @@ - hosts: mongodb gather_facts: no vars: - ansible_python_interpreter: /opt/py3/bin/python + ansible_python_interpreter: "{{ local_python_interpreter }}" tasks: - name: Test MongoDB connection diff --git a/apps/assets/automations/ping/database/mysql/main.yml b/apps/assets/automations/ping/database/mysql/main.yml index 8326402cd..853edbddb 100644 --- a/apps/assets/automations/ping/database/mysql/main.yml +++ b/apps/assets/automations/ping/database/mysql/main.yml @@ -1,7 +1,7 @@ - hosts: mysql gather_facts: no vars: - ansible_python_interpreter: /opt/py3/bin/python + ansible_python_interpreter: "{{ local_python_interpreter }}" check_ssl: "{{ jms_asset.spec_info.use_ssl and not jms_asset.spec_info.allow_invalid_cert }}" ca_cert: "{{ jms_asset.secret_info.ca_cert | default('') }}" ssl_cert: "{{ jms_asset.secret_info.client_cert | default('') }}" diff --git a/apps/assets/automations/ping/database/oracle/main.yml b/apps/assets/automations/ping/database/oracle/main.yml index 3cad226e8..f15f6e489 100644 --- a/apps/assets/automations/ping/database/oracle/main.yml +++ b/apps/assets/automations/ping/database/oracle/main.yml @@ -1,7 +1,7 @@ - hosts: oracle gather_facts: no vars: - ansible_python_interpreter: /opt/py3/bin/python + ansible_python_interpreter: "{{ local_python_interpreter }}" tasks: - name: Test Oracle connection diff --git a/apps/assets/automations/ping/database/postgresql/main.yml b/apps/assets/automations/ping/database/postgresql/main.yml index 3edc7daec..4e1982f1e 100644 --- a/apps/assets/automations/ping/database/postgresql/main.yml +++ b/apps/assets/automations/ping/database/postgresql/main.yml @@ -1,7 +1,7 @@ - hosts: postgre gather_facts: no vars: - ansible_python_interpreter: /opt/py3/bin/python + ansible_python_interpreter: "{{ local_python_interpreter }}" check_ssl: "{{ jms_asset.spec_info.use_ssl }}" ca_cert: "{{ jms_asset.secret_info.ca_cert | default('') }}" ssl_cert: "{{ jms_asset.secret_info.client_cert | default('') }}" diff --git a/apps/assets/automations/ping/database/sqlserver/main.yml b/apps/assets/automations/ping/database/sqlserver/main.yml index 4eb8fa077..7cac3199d 100644 --- a/apps/assets/automations/ping/database/sqlserver/main.yml +++ b/apps/assets/automations/ping/database/sqlserver/main.yml @@ -1,7 +1,7 @@ - hosts: sqlserver gather_facts: no vars: - ansible_python_interpreter: /opt/py3/bin/python + ansible_python_interpreter: "{{ local_python_interpreter }}" tasks: - name: Test SQLServer connection diff --git a/apps/assets/const/automation.py b/apps/assets/const/automation.py index 135102312..e70387cfe 100644 --- a/apps/assets/const/automation.py +++ b/apps/assets/const/automation.py @@ -4,6 +4,7 @@ from django.utils.translation import gettext_lazy as _ class Connectivity(TextChoices): UNKNOWN = '-', _('Unknown') + NA = 'na', _('N/A') OK = 'ok', _('OK') ERR = 'err', _('Error') diff --git a/apps/assets/const/device.py b/apps/assets/const/device.py index 212b18b99..8860dc3fb 100644 --- a/apps/assets/const/device.py +++ b/apps/assets/const/device.py @@ -4,6 +4,11 @@ from .base import BaseType class DeviceTypes(BaseType): + CISCO = 'cisco', _("Cisco") + HUAWEI = 'huawei', _("Huawei") + H3C = 'h3c', _("H3C") + JUNIPER = 'juniper', _("Juniper") + TP_LINK = 'tp_link', _("TP-Link") GENERAL = 'general', _("General") SWITCH = 'switch', _("Switch") ROUTER = 'router', _("Router") @@ -34,8 +39,7 @@ class DeviceTypes(BaseType): '*': { 'ansible_enabled': True, 'ansible_config': { - 'ansible_connection': 'local', - 'first_conn_delay_time': 0.5, + 'ansible_connection': 'local' }, 'ping_enabled': True, 'gather_facts_enabled': False, diff --git a/apps/assets/const/host.py b/apps/assets/const/host.py index 91942e294..8bd45f257 100644 --- a/apps/assets/const/host.py +++ b/apps/assets/const/host.py @@ -8,6 +8,7 @@ GATEWAY_NAME = 'Gateway' class HostTypes(BaseType): LINUX = 'linux', 'Linux' WINDOWS = 'windows', 'Windows' + MacOS = 'macos', 'macOS' UNIX = 'unix', 'Unix' OTHER_HOST = 'other', _("Other") diff --git a/apps/assets/migrations/0001_initial.py b/apps/assets/migrations/0001_initial.py index 1efb07ebb..d62a3cca9 100644 --- a/apps/assets/migrations/0001_initial.py +++ b/apps/assets/migrations/0001_initial.py @@ -11,7 +11,6 @@ import common.db.fields class Migration(migrations.Migration): - initial = True dependencies = [ @@ -27,8 +26,11 @@ class Migration(migrations.Migration): ('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')), - ('connectivity', models.CharField(choices=[('-', 'Unknown'), ('ok', 'OK'), ('err', 'Error')], default='-', max_length=16, verbose_name='Connectivity')), + ('org_id', + models.CharField(blank=True, db_index=True, default='', max_length=36, verbose_name='Organization')), + ('connectivity', + models.CharField(choices=[('-', 'Unknown'), ('na', 'N/A'), ('ok', 'OK'), ('err', 'Error')], + default='-', max_length=16, verbose_name='Connectivity')), ('date_verified', models.DateTimeField(null=True, verbose_name='Date verified')), ('name', models.CharField(max_length=128, verbose_name='Name')), ('address', models.CharField(db_index=True, max_length=767, verbose_name='Address')), @@ -39,21 +41,27 @@ class Migration(migrations.Migration): options={ 'verbose_name': 'Asset', 'ordering': [], - 'permissions': [('refresh_assethardwareinfo', 'Can refresh asset hardware info'), ('test_assetconnectivity', 'Can test asset connectivity'), ('match_asset', 'Can match asset'), ('change_assetnodes', 'Can change asset nodes')], + 'permissions': [('refresh_assethardwareinfo', 'Can refresh asset hardware info'), + ('test_assetconnectivity', 'Can test asset connectivity'), + ('match_asset', 'Can match asset'), ('change_assetnodes', 'Can change asset nodes')], }, - bases=(assets.models.asset.common.NodesRelationMixin, assets.models.asset.common.JSONFilterMixin, models.Model), + bases=( + assets.models.asset.common.NodesRelationMixin, assets.models.asset.common.JSONFilterMixin, models.Model), ), migrations.CreateModel( name='AutomationExecution', fields=[ - ('org_id', models.CharField(blank=True, db_index=True, default='', max_length=36, verbose_name='Organization')), + ('org_id', + models.CharField(blank=True, db_index=True, default='', max_length=36, verbose_name='Organization')), ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), ('status', models.CharField(default='pending', max_length=16, verbose_name='Status')), ('date_created', models.DateTimeField(auto_now_add=True, verbose_name='Date created')), ('date_start', models.DateTimeField(db_index=True, null=True, verbose_name='Date start')), ('date_finished', models.DateTimeField(null=True, verbose_name='Date finished')), - ('snapshot', common.db.fields.EncryptJsonDictTextField(blank=True, default=dict, null=True, verbose_name='Automation snapshot')), - ('trigger', models.CharField(choices=[('manual', 'Manual trigger'), ('timing', 'Timing trigger')], default='manual', max_length=128, verbose_name='Trigger mode')), + ('snapshot', common.db.fields.EncryptJsonDictTextField(blank=True, default=dict, null=True, + verbose_name='Automation snapshot')), + ('trigger', models.CharField(choices=[('manual', 'Manual'), ('timing', 'Timing')], default='manual', + max_length=128, verbose_name='Trigger mode')), ], options={ 'verbose_name': 'Automation task execution', @@ -69,7 +77,8 @@ class Migration(migrations.Migration): ('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')), + ('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')), ('is_periodic', models.BooleanField(default=False, verbose_name='Periodic run')), ('interval', models.IntegerField(blank=True, default=24, null=True, verbose_name='Interval')), @@ -92,7 +101,8 @@ class Migration(migrations.Migration): ('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')), + ('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')), ], options={ @@ -108,7 +118,8 @@ class Migration(migrations.Migration): ('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')), - ('org_id', models.CharField(blank=True, db_index=True, default='', max_length=36, verbose_name='Organization')), + ('org_id', + models.CharField(blank=True, db_index=True, default='', max_length=36, verbose_name='Organization')), ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), ('key', models.CharField(max_length=64, unique=True, verbose_name='Key')), ('value', models.CharField(max_length=128, verbose_name='Value')), @@ -123,7 +134,8 @@ class Migration(migrations.Migration): 'ordering': ['parent_key', 'value'], 'permissions': [('match_node', 'Can match node')], }, - bases=(models.Model, assets.models.node.SomeNodesMixin, assets.models.node.FamilyMixin, assets.models.node.NodeAssetsMixin), + bases=(models.Model, assets.models.node.SomeNodesMixin, assets.models.node.FamilyMixin, + assets.models.node.NodeAssetsMixin), ), migrations.CreateModel( name='Platform', @@ -139,7 +151,9 @@ class Migration(migrations.Migration): ('type', models.CharField(default='linux', max_length=32, verbose_name='Type')), ('meta', common.db.fields.JsonDictTextField(blank=True, null=True, verbose_name='Meta')), ('internal', models.BooleanField(default=False, verbose_name='Internal')), - ('charset', models.CharField(choices=[('utf-8', 'UTF-8'), ('gbk', 'GBK')], default='utf-8', max_length=8, verbose_name='Charset')), + ('charset', + models.CharField(choices=[('utf-8', 'UTF-8'), ('gbk', 'GBK')], default='utf-8', max_length=8, + verbose_name='Charset')), ('domain_enabled', models.BooleanField(default=True, verbose_name='Gateway enabled')), ('su_enabled', models.BooleanField(default=False, verbose_name='Su enabled')), ('su_method', models.CharField(blank=True, max_length=32, null=True, verbose_name='Su method')), @@ -152,7 +166,9 @@ class Migration(migrations.Migration): migrations.CreateModel( name='Cloud', fields=[ - ('asset_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='assets.asset')), + ('asset_ptr', + models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, + primary_key=True, serialize=False, to='assets.asset')), ], options={ 'verbose_name': 'Cloud', @@ -162,7 +178,9 @@ class Migration(migrations.Migration): migrations.CreateModel( name='Custom', fields=[ - ('asset_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='assets.asset')), + ('asset_ptr', + models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, + primary_key=True, serialize=False, to='assets.asset')), ], options={ 'verbose_name': 'Custom asset', @@ -172,7 +190,9 @@ class Migration(migrations.Migration): migrations.CreateModel( name='Database', fields=[ - ('asset_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='assets.asset')), + ('asset_ptr', + models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, + primary_key=True, serialize=False, to='assets.asset')), ('db_name', models.CharField(blank=True, max_length=1024, verbose_name='Database')), ('use_ssl', models.BooleanField(default=False, verbose_name='Use SSL')), ('ca_cert', common.db.fields.EncryptTextField(blank=True, verbose_name='CA cert')), @@ -188,7 +208,9 @@ class Migration(migrations.Migration): migrations.CreateModel( name='Device', fields=[ - ('asset_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='assets.asset')), + ('asset_ptr', + models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, + primary_key=True, serialize=False, to='assets.asset')), ], options={ 'verbose_name': 'Device', @@ -198,7 +220,9 @@ class Migration(migrations.Migration): migrations.CreateModel( name='GPT', fields=[ - ('asset_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='assets.asset')), + ('asset_ptr', + models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, + primary_key=True, serialize=False, to='assets.asset')), ('proxy', models.CharField(blank=True, default='', max_length=128, verbose_name='Proxy')), ], options={ @@ -209,7 +233,9 @@ class Migration(migrations.Migration): migrations.CreateModel( name='Host', fields=[ - ('asset_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='assets.asset')), + ('asset_ptr', + models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, + primary_key=True, serialize=False, to='assets.asset')), ], options={ 'verbose_name': 'Host', @@ -219,11 +245,17 @@ class Migration(migrations.Migration): migrations.CreateModel( name='Web', fields=[ - ('asset_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='assets.asset')), - ('autofill', models.CharField(choices=[('no', 'Disabled'), ('basic', 'Basic'), ('script', 'Script')], default='basic', max_length=16, verbose_name='Autofill')), - ('username_selector', models.CharField(blank=True, default='', max_length=128, verbose_name='Username selector')), - ('password_selector', models.CharField(blank=True, default='', max_length=128, verbose_name='Password selector')), - ('submit_selector', models.CharField(blank=True, default='', max_length=128, verbose_name='Submit selector')), + ('asset_ptr', + models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, + primary_key=True, serialize=False, to='assets.asset')), + ('autofill', models.CharField(choices=[('no', 'Disabled'), ('basic', 'Basic'), ('script', 'Script')], + default='basic', max_length=16, verbose_name='Autofill')), + ('username_selector', + models.CharField(blank=True, default='', max_length=128, verbose_name='Username selector')), + ('password_selector', + models.CharField(blank=True, default='', max_length=128, verbose_name='Password selector')), + ('submit_selector', + models.CharField(blank=True, default='', max_length=128, verbose_name='Submit selector')), ('script', models.JSONField(blank=True, default=list, verbose_name='Script')), ], options={ @@ -237,7 +269,8 @@ class Migration(migrations.Migration): ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(max_length=32, verbose_name='Name')), ('port', models.IntegerField(verbose_name='Port')), - ('asset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='protocols', to='assets.asset', verbose_name='Asset')), + ('asset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='protocols', + to='assets.asset', verbose_name='Asset')), ], ), migrations.CreateModel( @@ -251,7 +284,8 @@ class Migration(migrations.Migration): ('default', models.BooleanField(default=False, verbose_name='Default')), ('public', models.BooleanField(default=True, verbose_name='Public')), ('setting', models.JSONField(default=dict, verbose_name='Setting')), - ('platform', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='protocols', to='assets.platform')), + ('platform', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='protocols', + to='assets.platform')), ], ), migrations.CreateModel( @@ -264,24 +298,32 @@ class Migration(migrations.Migration): ('ping_method', models.CharField(blank=True, max_length=32, null=True, verbose_name='Ping method')), ('ping_params', models.JSONField(default=dict, verbose_name='Ping params')), ('gather_facts_enabled', models.BooleanField(default=False, verbose_name='Gather facts enabled')), - ('gather_facts_method', models.TextField(blank=True, max_length=32, null=True, verbose_name='Gather facts method')), + ('gather_facts_method', + models.TextField(blank=True, max_length=32, null=True, verbose_name='Gather facts method')), ('gather_facts_params', models.JSONField(default=dict, verbose_name='Gather facts params')), ('change_secret_enabled', models.BooleanField(default=False, verbose_name='Change secret enabled')), - ('change_secret_method', models.TextField(blank=True, max_length=32, null=True, verbose_name='Change secret method')), + ('change_secret_method', + models.TextField(blank=True, max_length=32, null=True, verbose_name='Change secret method')), ('change_secret_params', models.JSONField(default=dict, verbose_name='Change secret params')), ('push_account_enabled', models.BooleanField(default=False, verbose_name='Push account enabled')), - ('push_account_method', models.TextField(blank=True, max_length=32, null=True, verbose_name='Push account method')), + ('push_account_method', + models.TextField(blank=True, max_length=32, null=True, verbose_name='Push account method')), ('push_account_params', models.JSONField(default=dict, verbose_name='Push account params')), ('verify_account_enabled', models.BooleanField(default=False, verbose_name='Verify account enabled')), - ('verify_account_method', models.TextField(blank=True, max_length=32, null=True, verbose_name='Verify account method')), + ('verify_account_method', + models.TextField(blank=True, max_length=32, null=True, verbose_name='Verify account method')), ('verify_account_params', models.JSONField(default=dict, verbose_name='Verify account params')), ('gather_accounts_enabled', models.BooleanField(default=False, verbose_name='Gather facts enabled')), - ('gather_accounts_method', models.TextField(blank=True, max_length=32, null=True, verbose_name='Gather facts method')), + ('gather_accounts_method', + models.TextField(blank=True, max_length=32, null=True, verbose_name='Gather facts method')), ('gather_accounts_params', models.JSONField(default=dict, verbose_name='Gather facts params')), ('remove_account_enabled', models.BooleanField(default=False, verbose_name='Remove account enabled')), - ('remove_account_method', models.TextField(blank=True, max_length=32, null=True, verbose_name='Remove account method')), + ('remove_account_method', + models.TextField(blank=True, max_length=32, null=True, verbose_name='Remove account method')), ('remove_account_params', models.JSONField(default=dict, verbose_name='Remove account params')), - ('platform', models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='automation', to='assets.platform')), + ('platform', + models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='automation', + to='assets.platform')), ], ), migrations.CreateModel( @@ -293,10 +335,12 @@ class Migration(migrations.Migration): ('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')), + ('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')), ('value', models.CharField(max_length=128, verbose_name='Value')), - ('category', models.CharField(choices=[('S', 'System'), ('U', 'User')], default='U', max_length=128, verbose_name='Category')), + ('category', models.CharField(choices=[('S', 'System'), ('U', 'User')], default='U', max_length=128, + verbose_name='Category')), ('is_active', models.BooleanField(default=True, verbose_name='Is active')), ], options={ diff --git a/apps/assets/migrations/0006_baseautomation_start_time.py b/apps/assets/migrations/0006_baseautomation_start_time.py new file mode 100644 index 000000000..7e3f925c1 --- /dev/null +++ b/apps/assets/migrations/0006_baseautomation_start_time.py @@ -0,0 +1,23 @@ +# Generated by Django 4.1.13 on 2024-10-15 02:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("assets", "0005_myasset"), + ] + + operations = [ + migrations.AddField( + model_name="baseautomation", + name="start_time", + field=models.DateTimeField( + blank=True, + help_text="Datetime when the schedule should begin triggering the task to run", + null=True, + verbose_name="Start Datetime", + ), + ), + ] diff --git a/apps/assets/migrations/0007_baseautomation_date_last_run_and_more.py b/apps/assets/migrations/0007_baseautomation_date_last_run_and_more.py new file mode 100644 index 000000000..798619d54 --- /dev/null +++ b/apps/assets/migrations/0007_baseautomation_date_last_run_and_more.py @@ -0,0 +1,27 @@ +# Generated by Django 4.1.13 on 2024-11-14 06:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("assets", "0006_baseautomation_start_time"), + ] + + operations = [ + migrations.AddField( + model_name="baseautomation", + name="date_last_run", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Date last run" + ), + ), + migrations.AlterField( + model_name="baseautomation", + name="crontab", + field=models.CharField( + blank=True, default="", max_length=128, verbose_name="Crontab" + ), + ), + ] diff --git a/apps/assets/migrations/0008_automationexecution_result_and_more.py b/apps/assets/migrations/0008_automationexecution_result_and_more.py new file mode 100644 index 000000000..48a371488 --- /dev/null +++ b/apps/assets/migrations/0008_automationexecution_result_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.1.13 on 2024-11-15 10:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("assets", "0007_baseautomation_date_last_run_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="automationexecution", + name="result", + field=models.JSONField(default=dict, verbose_name="Result"), + ), + migrations.AddField( + model_name="automationexecution", + name="summary", + field=models.JSONField(default=dict, verbose_name="Summary"), + ), + ] diff --git a/apps/assets/migrations/0009_automationexecution_duration.py b/apps/assets/migrations/0009_automationexecution_duration.py new file mode 100644 index 000000000..ab905469c --- /dev/null +++ b/apps/assets/migrations/0009_automationexecution_duration.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.13 on 2024-11-15 10:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("assets", "0008_automationexecution_result_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="automationexecution", + name="duration", + field=models.FloatField(default=0, verbose_name="Duration"), + ), + ] diff --git a/apps/assets/migrations/0010_alter_automationexecution_duration.py b/apps/assets/migrations/0010_alter_automationexecution_duration.py new file mode 100644 index 000000000..b9c6708c2 --- /dev/null +++ b/apps/assets/migrations/0010_alter_automationexecution_duration.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.13 on 2024-11-18 02:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("assets", "0009_automationexecution_duration"), + ] + + operations = [ + migrations.AlterField( + model_name="automationexecution", + name="duration", + field=models.IntegerField(default=0, verbose_name="Duration"), + ), + ] diff --git a/apps/assets/migrations/0011_auto_20241204_1516.py b/apps/assets/migrations/0011_auto_20241204_1516.py new file mode 100644 index 000000000..b9ca490d4 --- /dev/null +++ b/apps/assets/migrations/0011_auto_20241204_1516.py @@ -0,0 +1,41 @@ +# Generated by Django 4.1.13 on 2024-12-04 07:16 + +from django.db import migrations, models + + +def migrate_platform_sqlserver_automation(apps, schema_editor): + platform_model = apps.get_model('assets', 'Platform') + platform = platform_model.objects.filter(name='SQLServer').first() + + if platform: + automation = platform.automation + automation.gather_accounts_method = 'gather_accounts_sqlserver' + automation.save() + + +class Migration(migrations.Migration): + dependencies = [ + ('assets', '0010_alter_automationexecution_duration'), + ] + + operations = [ + migrations.AlterField( + model_name="automationexecution", + name="status", + field=models.CharField( + choices=[ + ("ready", "Ready"), + ("pending", "Pending"), + ("running", "Running"), + ("success", "Success"), + ("failed", "Failed"), + ("error", "Error"), + ("canceled", "Canceled"), + ], + default="pending", + max_length=16, + verbose_name="Status", + ), + ), + migrations.RunPython(migrate_platform_sqlserver_automation) + ] diff --git a/apps/assets/models/asset/common.py b/apps/assets/models/asset/common.py index 833d8f1be..16f9a8751 100644 --- a/apps/assets/models/asset/common.py +++ b/apps/assets/models/asset/common.py @@ -245,6 +245,10 @@ class Asset(NodesRelationMixin, LabeledMixin, AbsConnectivity, JSONFilterMixin, auto_config.update(model_to_dict(automation)) return auto_config + @lazyproperty + def accounts_amount(self): + return self.accounts.count() + def get_target_ip(self): return self.address diff --git a/apps/assets/models/automations/base.py b/apps/assets/models/automations/base.py index cdc3babad..2df9aff61 100644 --- a/apps/assets/models/automations/base.py +++ b/apps/assets/models/automations/base.py @@ -7,10 +7,11 @@ from django.utils.translation import gettext_lazy as _ from assets.models.asset import Asset from assets.models.node import Node from assets.tasks import execute_asset_automation_task -from common.const.choices import Trigger +from common.const.choices import Trigger, Status from common.db.fields import EncryptJsonDictTextField from ops.mixin import PeriodTaskModelMixin from orgs.mixins.models import OrgModelMixin, JMSOrgBaseModel, OrgManager +from users.models import User class BaseAutomationManager(OrgManager): @@ -19,19 +20,24 @@ class BaseAutomationManager(OrgManager): class BaseAutomation(PeriodTaskModelMixin, JMSOrgBaseModel): accounts = models.JSONField(default=list, verbose_name=_("Accounts")) - nodes = models.ManyToManyField('assets.Node', blank=True, verbose_name=_("Node")) - assets = models.ManyToManyField('assets.Asset', blank=True, verbose_name=_("Assets")) - type = models.CharField(max_length=16, verbose_name=_('Type')) + nodes = models.ManyToManyField("assets.Node", blank=True, verbose_name=_("Node")) + assets = models.ManyToManyField( + "assets.Asset", blank=True, verbose_name=_("Assets") + ) + type = models.CharField(max_length=16, verbose_name=_("Type")) is_active = models.BooleanField(default=True, verbose_name=_("Is active")) params = models.JSONField(default=dict, verbose_name=_("Parameters")) objects = BaseAutomationManager.from_queryset(models.QuerySet)() + def get_report_template(self): + raise NotImplementedError + def __str__(self): - return self.name + '@' + str(self.created_by) + return self.name + "@" + str(self.created_by) class Meta: - unique_together = [('org_id', 'name', 'type')] + unique_together = [("org_id", "name", "type")] verbose_name = _("Automation task") @classmethod @@ -45,13 +51,13 @@ class BaseAutomation(PeriodTaskModelMixin, JMSOrgBaseModel): def get_all_assets(self): nodes = self.nodes.all() - node_asset_ids = Node.get_nodes_all_assets(*nodes).values_list('id', flat=True) - direct_asset_ids = self.assets.all().values_list('id', flat=True) + node_asset_ids = Node.get_nodes_all_assets(*nodes).values_list("id", flat=True) + direct_asset_ids = self.assets.all().values_list("id", flat=True) asset_ids = set(list(direct_asset_ids) + list(node_asset_ids)) return Asset.objects.filter(id__in=asset_ids) def all_assets_group_by_platform(self): - assets = self.get_all_assets().prefetch_related('platform') + assets = self.get_all_assets().prefetch_related("platform") return assets.group_by_platform() @property @@ -66,17 +72,18 @@ class BaseAutomation(PeriodTaskModelMixin, JMSOrgBaseModel): return name, task, args, kwargs def get_many_to_many_ids(self, field: str): - return [str(i) for i in getattr(self, field).all().values_list('id', flat=True)] + return [str(i) for i in getattr(self, field).all().values_list("id", flat=True)] def to_attr_json(self): return { - 'name': self.name, - 'type': self.type, - 'comment': self.comment, - 'accounts': self.accounts, - 'org_id': str(self.org_id), - 'nodes': self.get_many_to_many_ids('nodes'), - 'assets': self.get_many_to_many_ids('assets'), + "name": self.name, + "type": self.type, + "comment": self.comment, + "accounts": self.accounts, + "params": self.params, + "org_id": str(self.org_id), + "nodes": self.get_many_to_many_ids("nodes"), + "assets": self.get_many_to_many_ids("assets"), } @property @@ -98,7 +105,9 @@ class BaseAutomation(PeriodTaskModelMixin, JMSOrgBaseModel): eid = str(uuid.uuid4()) execution = self.execution_model.objects.create( - id=eid, trigger=trigger, automation=self, + id=eid, + trigger=trigger, + automation=self, snapshot=self.to_attr_json(), ) return execution.start() @@ -113,34 +122,64 @@ class AssetBaseAutomation(BaseAutomation): class AutomationExecution(OrgModelMixin): id = models.UUIDField(default=uuid.uuid4, primary_key=True) automation = models.ForeignKey( - 'BaseAutomation', related_name='executions', on_delete=models.CASCADE, - verbose_name=_('Automation task'), null=True + "BaseAutomation", + related_name="executions", + on_delete=models.CASCADE, + verbose_name=_("Automation task"), + null=True, + ) + # pending, running, success, failed, terminated + status = models.CharField( + max_length=16, default=Status.pending, choices=Status.choices, verbose_name=_("Status") + ) + date_created = models.DateTimeField( + auto_now_add=True, verbose_name=_("Date created") + ) + date_start = models.DateTimeField( + null=True, verbose_name=_("Date start"), db_index=True ) - status = models.CharField(max_length=16, default='pending', verbose_name=_('Status')) - date_created = models.DateTimeField(auto_now_add=True, verbose_name=_('Date created')) - date_start = models.DateTimeField(null=True, verbose_name=_('Date start'), db_index=True) date_finished = models.DateTimeField(null=True, verbose_name=_("Date finished")) + duration = models.IntegerField(default=0, verbose_name=_("Duration")) snapshot = EncryptJsonDictTextField( - default=dict, blank=True, null=True, verbose_name=_('Automation snapshot') + default=dict, blank=True, null=True, verbose_name=_("Automation snapshot") ) trigger = models.CharField( - max_length=128, default=Trigger.manual, choices=Trigger.choices, - verbose_name=_('Trigger mode') + max_length=128, + default=Trigger.manual, + choices=Trigger.choices, + verbose_name=_("Trigger mode"), ) + summary = models.JSONField(default=dict, verbose_name=_("Summary")) + result = models.JSONField(default=dict, verbose_name=_("Result")) class Meta: - ordering = ('org_id', '-date_start',) - verbose_name = _('Automation task execution') + ordering = ( + "org_id", + "-date_start", + ) + verbose_name = _("Automation task execution") + + @property + def short_id(self): + return str(self.id)[:8] + + @property + def is_finished(self): + return bool(self.date_finished) + + @property + def is_success(self): + return self.status == Status.success @property def manager_type(self): - return self.snapshot['type'] + return self.snapshot["type"] def get_all_asset_ids(self): - node_ids = self.snapshot['nodes'] - asset_ids = self.snapshot['assets'] + node_ids = self.snapshot.get("nodes", []) + asset_ids = self.snapshot.get("assets", []) nodes = Node.objects.filter(id__in=node_ids) - node_asset_ids = Node.get_nodes_all_assets(*nodes).values_list('id', flat=True) + node_asset_ids = Node.get_nodes_all_assets(*nodes).values_list("id", flat=True) asset_ids = set(list(asset_ids) + list(node_asset_ids)) return asset_ids @@ -149,17 +188,22 @@ class AutomationExecution(OrgModelMixin): return Asset.objects.filter(id__in=asset_ids) def all_assets_group_by_platform(self): - assets = self.get_all_assets().prefetch_related('platform') + assets = self.get_all_assets().prefetch_related("platform") return assets.group_by_platform() @property def recipients(self): - recipients = self.snapshot.get('recipients') + recipients = self.snapshot.get("recipients") if not recipients: - return {} - return recipients + return [] + users = User.objects.filter(id__in=recipients) + return users + + @property + def manager(self): + from assets.automations.endpoint import ExecutionManager + + return ExecutionManager(execution=self) def start(self): - from assets.automations.endpoint import ExecutionManager - manager = ExecutionManager(execution=self) - return manager.run() + return self.manager.run() diff --git a/apps/assets/models/base.py b/apps/assets/models/base.py index c4866c22b..031335b72 100644 --- a/apps/assets/models/base.py +++ b/apps/assets/models/base.py @@ -6,9 +6,7 @@ from django.utils import timezone from django.utils.translation import gettext_lazy as _ from assets.const import Connectivity -from common.utils import ( - get_logger -) +from common.utils import get_logger logger = get_logger(__file__) diff --git a/apps/assets/serializers/asset/common.py b/apps/assets/serializers/asset/common.py index 04bdf6010..05a03fb0e 100644 --- a/apps/assets/serializers/asset/common.py +++ b/apps/assets/serializers/asset/common.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # -from django.db.models import F +from django.db.models import F, Count from django.db.transaction import atomic from django.utils.translation import gettext_lazy as _ from rest_framework import serializers @@ -14,7 +14,7 @@ from common.serializers import ( CommonModelSerializer, MethodSerializer, ResourceLabelsMixin ) from common.serializers.common import DictSerializer -from common.serializers.fields import LabeledChoiceField +from common.serializers.fields import LabeledChoiceField, ObjectRelatedField from labels.models import Label from orgs.mixins.serializers import BulkOrgResourceModelSerializer from ...const import Category, AllTypes @@ -147,6 +147,8 @@ class AssetSerializer(BulkOrgResourceModelSerializer, ResourceLabelsMixin, Writa protocols = AssetProtocolsSerializer(many=True, required=False, label=_('Protocols'), default=()) accounts = AssetAccountSerializer(many=True, required=False, allow_null=True, write_only=True, label=_('Accounts')) nodes_display = NodeDisplaySerializer(read_only=False, required=False, label=_("Node path")) + platform = ObjectRelatedField(queryset=Platform.objects, required=True, label=_('Platform'), attrs=('id', 'name', 'type')) + accounts_amount = serializers.IntegerField(read_only=True, label=_('Accounts amount')) _accounts = None class Meta: @@ -159,7 +161,7 @@ class AssetSerializer(BulkOrgResourceModelSerializer, ResourceLabelsMixin, Writa 'nodes_display', 'accounts', ] read_only_fields = [ - 'category', 'type', 'connectivity', 'auto_config', + 'accounts_amount', 'category', 'type', 'connectivity', 'auto_config', 'date_verified', 'created_by', 'date_created', 'date_updated', ] fields = fields_small + fields_fk + fields_m2m + read_only_fields @@ -227,7 +229,8 @@ class AssetSerializer(BulkOrgResourceModelSerializer, ResourceLabelsMixin, Writa queryset = queryset.prefetch_related('domain', 'nodes', 'protocols', ) \ .prefetch_related('platform', 'platform__automation') \ .annotate(category=F("platform__category")) \ - .annotate(type=F("platform__type")) + .annotate(type=F("platform__type")) \ + .annotate(accounts_amount=Count('accounts')) if queryset.model is Asset: queryset = queryset.prefetch_related('labels__label', 'labels') else: diff --git a/apps/assets/serializers/automations/base.py b/apps/assets/serializers/automations/base.py index 522049d17..184c6bd37 100644 --- a/apps/assets/serializers/automations/base.py +++ b/apps/assets/serializers/automations/base.py @@ -2,7 +2,7 @@ from django.utils.translation import gettext_lazy as _ from rest_framework import serializers from assets.models import Asset, Node, BaseAutomation, AutomationExecution -from common.const.choices import Trigger +from common.const.choices import Trigger, Status from common.serializers.fields import ObjectRelatedField, LabeledChoiceField from common.utils import get_logger from ops.mixin import PeriodTaskSerializerMixin @@ -22,37 +22,36 @@ class BaseAutomationSerializer(PeriodTaskSerializerMixin, BulkOrgResourceModelSe class Meta: read_only_fields = [ - 'date_created', 'date_updated', 'created_by', 'periodic_display' + 'date_created', 'date_updated', 'created_by', + 'periodic_display', 'executed_amount', 'type' ] - fields = [ - 'id', 'name', 'is_periodic', 'interval', 'crontab', 'comment', - 'type', 'accounts', 'nodes', 'assets', 'is_active' - ] + read_only_fields + mini_fields = [ + 'id', 'name', 'type', 'is_periodic', 'interval', + 'crontab', 'comment', 'is_active' + ] + fields = mini_fields + [ + 'accounts', 'nodes', 'assets', + ] + read_only_fields extra_kwargs = { 'name': {'required': True}, 'type': {'read_only': True}, + 'executed_amount': {'label': _('Executions')}, } class AutomationExecutionSerializer(serializers.ModelSerializer): snapshot = serializers.SerializerMethodField(label=_('Automation snapshot')) - status = serializers.SerializerMethodField(label=_("Status")) trigger = LabeledChoiceField(choices=Trigger.choices, read_only=True, label=_("Trigger mode")) + status = LabeledChoiceField(choices=Status.choices, read_only=True, label=_('Status')) + short_id = serializers.CharField(read_only=True, label=_('Short ID')) class Meta: model = AutomationExecution read_only_fields = [ - 'trigger', 'date_start', 'date_finished', 'snapshot', 'status' + 'trigger', 'date_start', 'date_finished', + 'snapshot', 'status', 'duration' ] - fields = ['id', 'automation'] + read_only_fields - - @staticmethod - def get_status(obj): - if obj.status == 'success': - return _("Success") - elif obj.status == 'pending': - return _("Pending") - return obj.status + fields = ['id', 'short_id', 'automation'] + read_only_fields @staticmethod def get_snapshot(obj): diff --git a/apps/assets/serializers/platform.py b/apps/assets/serializers/platform.py index 38c8bd134..2d25f2fe3 100644 --- a/apps/assets/serializers/platform.py +++ b/apps/assets/serializers/platform.py @@ -6,7 +6,8 @@ from rest_framework.validators import UniqueValidator from assets.models import Asset from common.serializers import ( WritableNestedModelSerializer, type_field_map, MethodSerializer, - DictSerializer, create_serializer_class, ResourceLabelsMixin + DictSerializer, create_serializer_class, ResourceLabelsMixin, + CommonSerializerMixin ) from common.serializers.fields import LabeledChoiceField, ObjectRelatedField from common.utils import lazyproperty @@ -162,7 +163,7 @@ class PlatformCustomField(serializers.Serializer): choices = serializers.ListField(default=list, label=_("Choices"), required=False) -class PlatformSerializer(ResourceLabelsMixin, WritableNestedModelSerializer): +class PlatformSerializer(ResourceLabelsMixin, CommonSerializerMixin, WritableNestedModelSerializer): id = serializers.IntegerField( label='ID', required=False, validators=[UniqueValidator(queryset=Platform.objects.all())] diff --git a/apps/assets/tasks/common.py b/apps/assets/tasks/common.py index 1931347b1..977347000 100644 --- a/apps/assets/tasks/common.py +++ b/apps/assets/tasks/common.py @@ -45,3 +45,4 @@ def quickstart_automation(task_name, tp, task_snapshot=None): trigger=Trigger.manual, **data ) execution.start() + return execution diff --git a/apps/audits/api.py b/apps/audits/api.py index 086a75392..64ae15429 100644 --- a/apps/audits/api.py +++ b/apps/audits/api.py @@ -37,13 +37,14 @@ from .const import ActivityChoices from .filters import UserSessionFilterSet, OperateLogFilterSet from .models import ( FTPLog, UserLoginLog, OperateLog, PasswordChangeLog, - ActivityLog, JobLog, UserSession + ActivityLog, JobLog, UserSession, IntegrationApplicationLog ) from .serializers import ( FTPLogSerializer, UserLoginLogSerializer, JobLogSerializer, OperateLogSerializer, OperateLogActionDetailSerializer, PasswordChangeLogSerializer, ActivityUnionLogSerializer, - FileSerializer, UserSessionSerializer, JobsAuditSerializer + FileSerializer, UserSessionSerializer, JobsAuditSerializer, + ServiceAccessLogSerializer ) from .utils import construct_userlogin_usernames @@ -329,3 +330,15 @@ class UserSessionViewSet(CommonApiMixin, viewsets.ModelViewSet): user_session_manager.remove(key) queryset.delete() return Response(status=status.HTTP_200_OK) + + +class ServiceAccessLogViewSet(OrgReadonlyModelViewSet): + model = IntegrationApplicationLog + 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/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: diff --git a/apps/audits/migrations/0004_serviceaccesslog.py b/apps/audits/migrations/0004_serviceaccesslog.py new file mode 100644 index 000000000..ae4c69f53 --- /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='Application')), + ('service_id', models.UUIDField(verbose_name='Application 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/migrations/0005_rename_serviceaccesslog.py b/apps/audits/migrations/0005_rename_serviceaccesslog.py new file mode 100644 index 000000000..516ebeb7f --- /dev/null +++ b/apps/audits/migrations/0005_rename_serviceaccesslog.py @@ -0,0 +1,17 @@ +# Generated by Django 4.1.13 on 2024-12-04 09:11 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("audits", "0004_serviceaccesslog"), + ] + + operations = [ + migrations.RenameModel( + old_name="ServiceAccessLog", + new_name="IntegrationApplicationLog", + ), + ] diff --git a/apps/audits/models.py b/apps/audits/models.py index a06724803..1ad4b02ef 100644 --- a/apps/audits/models.py +++ b/apps/audits/models.py @@ -27,13 +27,14 @@ from .const import ( ) __all__ = [ + "JobLog", "FTPLog", "OperateLog", + "UserSession", "ActivityLog", - "PasswordChangeLog", "UserLoginLog", - "JobLog", - "UserSession" + "PasswordChangeLog", + "IntegrationApplicationLog", ] @@ -301,3 +302,13 @@ class UserSession(models.Model): permissions = [ ('offline_usersession', _('Offline user session')), ] + + +class IntegrationApplicationLog(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=_("Application")) + service_id = models.UUIDField(verbose_name=_("Application 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 48c408fe2..631c54c3c 100644 --- a/apps/audits/serializers.py +++ b/apps/audits/serializers.py @@ -213,3 +213,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.IntegrationApplicationLog + 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 8b34602db..8471196c8 100644 --- a/apps/audits/urls/api_urls.py +++ b/apps/audits/urls/api_urls.py @@ -18,6 +18,7 @@ router.register(r'jobs', api.JobsAuditViewSet, 'job') 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/api/connection_token.py b/apps/authentication/api/connection_token.py index 1d5c22514..5300e4c27 100644 --- a/apps/authentication/api/connection_token.py +++ b/apps/authentication/api/connection_token.py @@ -29,16 +29,16 @@ from terminal.models import EndpointRule, Endpoint from users.const import FileNameConflictResolution from users.const import RDPSmartSize, RDPColorQuality from users.models import Preference +from ..models import ConnectionToken, AdminConnectionToken, date_expired_default from .face import FaceMonitorContext from ..mixins import AuthFaceMixin -from ..models import ConnectionToken, date_expired_default from ..serializers import ( ConnectionTokenSerializer, ConnectionTokenSecretSerializer, SuperConnectionTokenSerializer, ConnectTokenAppletOptionSerializer, ConnectionTokenReusableSerializer, ConnectTokenVirtualAppOptionSerializer ) -__all__ = ['ConnectionTokenViewSet', 'SuperConnectionTokenViewSet'] +__all__ = ['ConnectionTokenViewSet', 'SuperConnectionTokenViewSet', 'AdminConnectionTokenViewSet'] logger = get_logger(__name__) @@ -664,3 +664,14 @@ class SuperConnectionTokenViewSet(ConnectionTokenViewSet): else: logger.error('Release applet account error: {}'.format(lock_key)) return Response({'error': 'not found or expired'}, status=400) + + +class AdminConnectionTokenViewSet(ConnectionTokenViewSet): + + def check_permissions(self, request): + user = request.user + if not user.is_superuser: + self.permission_denied(request) + + def get_queryset(self): + return AdminConnectionToken.objects.all() diff --git a/apps/authentication/backends/drf.py b/apps/authentication/backends/drf.py index 4cf2577c1..85bde3da2 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 IntegrationApplication 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=()): + IntegrationApplication.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 = IntegrationApplication.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/const.py b/apps/authentication/const.py index 9c9355caf..5ecabb045 100644 --- a/apps/authentication/const.py +++ b/apps/authentication/const.py @@ -49,3 +49,9 @@ class FaceMonitorActionChoices(TextChoices): Verify = 'verify', 'verify' Pause = 'pause', 'pause' Resume = 'resume', 'resume' + + +class ConnectionTokenType(TextChoices): + ADMIN = 'admin', 'Admin' + SUPER = 'super', 'Super' + USER = 'user', 'User' diff --git a/apps/authentication/mfa/face.py b/apps/authentication/mfa/face.py index d04d1e6b1..62acb99ce 100644 --- a/apps/authentication/mfa/face.py +++ b/apps/authentication/mfa/face.py @@ -1,9 +1,8 @@ -from authentication.mfa.base import BaseMFA +from django.conf import settings from django.utils.translation import gettext_lazy as _ +from authentication.mfa.base import BaseMFA from authentication.mixins import AuthFaceMixin -from common.const import LicenseEditionChoices -from settings.api import settings class MFAFace(BaseMFA, AuthFaceMixin): @@ -31,9 +30,9 @@ class MFAFace(BaseMFA, AuthFaceMixin): @staticmethod def global_enabled(): return ( - settings.XPACK_LICENSE_IS_VALID and - settings.XPACK_LICENSE_EDITION_ULTIMATE and - settings.FACE_RECOGNITION_ENABLED + settings.XPACK_LICENSE_IS_VALID and + settings.XPACK_LICENSE_EDITION_ULTIMATE and + settings.FACE_RECOGNITION_ENABLED ) def get_enable_url(self) -> str: diff --git a/apps/authentication/migrations/0001_initial.py b/apps/authentication/migrations/0001_initial.py index 7b332051f..11f7bcd21 100644 --- a/apps/authentication/migrations/0001_initial.py +++ b/apps/authentication/migrations/0001_initial.py @@ -1,14 +1,15 @@ # Generated by Django 4.1.13 on 2024-05-09 03:16 +import uuid + +from django.db import migrations, models + import authentication.models.access_key import authentication.models.connection_token import common.db.fields -from django.db import migrations, models -import uuid class Migration(migrations.Migration): - initial = True dependencies = [ @@ -18,9 +19,11 @@ class Migration(migrations.Migration): migrations.CreateModel( name='AccessKey', fields=[ - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, verbose_name='AccessKeyID')), - ('secret', models.CharField(default=authentication.models.access_key.default_secret, max_length=36, verbose_name='AccessKeySecret')), - ('ip_group', models.JSONField(default=authentication.models.access_key.default_ip_group, verbose_name='IP group')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, + verbose_name='AccessKeyID')), + ('secret', models.CharField(default=authentication.models.default_secret, max_length=36, + verbose_name='AccessKeySecret')), + ('ip_group', models.JSONField(default=authentication.models.default_ip_group, verbose_name='IP group')), ('is_active', models.BooleanField(default=True, verbose_name='Active')), ('date_last_used', models.DateTimeField(blank=True, null=True, verbose_name='Date last used')), ('date_created', models.DateTimeField(auto_now_add=True)), @@ -38,24 +41,30 @@ class Migration(migrations.Migration): ('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')), + ('org_id', + models.CharField(blank=True, db_index=True, default='', max_length=36, verbose_name='Organization')), ('value', models.CharField(default='', max_length=64, verbose_name='Value')), ('account', models.CharField(max_length=128, verbose_name='Account name')), - ('input_username', models.CharField(blank=True, default='', max_length=128, verbose_name='Input username')), - ('input_secret', common.db.fields.EncryptTextField(blank=True, default='', max_length=64, verbose_name='Input secret')), + ('input_username', + models.CharField(blank=True, default='', max_length=128, verbose_name='Input username')), + ('input_secret', + common.db.fields.EncryptTextField(blank=True, default='', max_length=64, verbose_name='Input secret')), ('protocol', models.CharField(default='ssh', max_length=16, verbose_name='Protocol')), ('connect_method', models.CharField(max_length=32, verbose_name='Connect method')), ('connect_options', models.JSONField(default=dict, verbose_name='Connect options')), ('user_display', models.CharField(default='', max_length=128, verbose_name='User display')), ('asset_display', models.CharField(default='', max_length=128, verbose_name='Asset display')), ('is_reusable', models.BooleanField(default=False, verbose_name='Reusable')), - ('date_expired', models.DateTimeField(default=authentication.models.connection_token.date_expired_default, verbose_name='Date expired')), + ('date_expired', + models.DateTimeField(default=authentication.models.connection_token.date_expired_default, + verbose_name='Date expired')), ('is_active', models.BooleanField(default=True, verbose_name='Active')), ], options={ 'verbose_name': 'Connection token', 'ordering': ('-date_expired',), - 'permissions': [('expire_connectiontoken', 'Can expire connection token'), ('reuse_connectiontoken', 'Can reuse connection token')], + 'permissions': [('expire_connectiontoken', 'Can expire connection token'), + ('reuse_connectiontoken', 'Can reuse connection token')], }, ), migrations.CreateModel( @@ -98,7 +107,8 @@ class Migration(migrations.Migration): ('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')), - ('authkey', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='Token')), + ('authkey', + models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='Token')), ('expired', models.BooleanField(default=False, verbose_name='Expired')), ], options={ diff --git a/apps/authentication/migrations/0004_connectiontoken_type.py b/apps/authentication/migrations/0004_connectiontoken_type.py new file mode 100644 index 000000000..02f22686b --- /dev/null +++ b/apps/authentication/migrations/0004_connectiontoken_type.py @@ -0,0 +1,30 @@ +# Generated by Django 4.1.13 on 2024-11-11 11:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0003_sshkey'), + ] + + operations = [ + migrations.AddField( + model_name='connectiontoken', + name='type', + field=models.CharField(choices=[('admin', 'Admin'), ('super', 'Super'), ('user', 'User')], default='user', max_length=16, verbose_name='Type'), + ), + migrations.CreateModel( + name='AdminConnectionToken', + fields=[ + ], + options={ + 'verbose_name': 'Admin connection token', + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('authentication.connectiontoken',), + ), + ] 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/authentication/models/connection_token.py b/apps/authentication/models/connection_token.py index cabb8932d..17c3625e6 100644 --- a/apps/authentication/models/connection_token.py +++ b/apps/authentication/models/connection_token.py @@ -12,6 +12,7 @@ from rest_framework.exceptions import PermissionDenied from accounts.models import VirtualAccount from assets.const import Protocol from assets.const.host import GATEWAY_NAME +from authentication.const import ConnectionTokenType from common.db.fields import EncryptTextField from common.exceptions import JMSException from common.utils import lazyproperty, pretty_string, bulk_get @@ -26,6 +27,8 @@ def date_expired_default(): class ConnectionToken(JMSOrgBaseModel): + _type = ConnectionTokenType.USER + value = models.CharField(max_length=64, default='', verbose_name=_("Value")) user = models.ForeignKey( 'users.User', on_delete=models.SET_NULL, null=True, blank=True, @@ -53,6 +56,11 @@ class ConnectionToken(JMSOrgBaseModel): face_monitor_token = models.CharField(max_length=128, null=True, blank=True, verbose_name=_("Face monitor token")) is_active = models.BooleanField(default=True, verbose_name=_("Active")) + type = models.CharField( + max_length=16, choices=ConnectionTokenType.choices, + default=ConnectionTokenType.USER, verbose_name=_('Type') + ) + class Meta: ordering = ('-date_expired',) permissions = [ @@ -61,6 +69,10 @@ class ConnectionToken(JMSOrgBaseModel): ] verbose_name = _('Connection token') + def save(self, *args, **kwargs): + self.type = self._meta.model._type + return super().save(*args, **kwargs) + @property def is_expired(self): return self.date_expired < timezone.now() @@ -269,9 +281,28 @@ class ConnectionToken(JMSOrgBaseModel): class SuperConnectionToken(ConnectionToken): + _type = ConnectionTokenType.SUPER + class Meta: proxy = True permissions = [ ('view_superconnectiontokensecret', _('Can view super connection token secret')) ] verbose_name = _("Super connection token") + + +class AdminConnectionTokenManager(models.Manager): + def get_queryset(self): + queryset = super().get_queryset() + queryset = queryset.filter(type=ConnectionTokenType.ADMIN) + return queryset + + +class AdminConnectionToken(ConnectionToken): + _type = ConnectionTokenType.ADMIN + + objects = AdminConnectionTokenManager() + + class Meta: + proxy = True + verbose_name = _("Admin connection token") diff --git a/apps/authentication/templates/authentication/login.html b/apps/authentication/templates/authentication/login.html index 47bc3fb8b..88899c044 100644 --- a/apps/authentication/templates/authentication/login.html +++ b/apps/authentication/templates/authentication/login.html @@ -402,6 +402,14 @@ + {% if demo_mode %} +
+

+ {% trans 'Username' %}: demo {% trans 'Password' %}: jumpserver +

+
+ {% endif %} +