From b16304c48ac9c46be6400769f4fb1d026cef71b9 Mon Sep 17 00:00:00 2001 From: Aaron3S Date: Wed, 8 Oct 2025 00:17:14 +0800 Subject: [PATCH] feat: data masking --- apps/acls/api/__init__.py | 1 + apps/acls/api/data_masking.py | 26 +++++++++++ apps/acls/migrations/0003_datamaskingrule.py | 45 +++++++++++++++++++ apps/acls/models/__init__.py | 1 + apps/acls/models/data_masking.py | 42 +++++++++++++++++ apps/acls/serializers/__init__.py | 1 + apps/acls/serializers/data_masking.py | 18 ++++++++ apps/acls/urls/api_urls.py | 1 + .../authentication/models/connection_token.py | 12 +++++ .../serializers/connect_token_secret.py | 15 +++++-- 10 files changed, 159 insertions(+), 3 deletions(-) create mode 100644 apps/acls/api/data_masking.py create mode 100644 apps/acls/migrations/0003_datamaskingrule.py create mode 100644 apps/acls/models/data_masking.py create mode 100644 apps/acls/serializers/data_masking.py diff --git a/apps/acls/api/__init__.py b/apps/acls/api/__init__.py index d3fa9cdc6..678e79a41 100644 --- a/apps/acls/api/__init__.py +++ b/apps/acls/api/__init__.py @@ -3,3 +3,4 @@ from .connect_method import * from .login_acl import * from .login_asset_acl import * from .login_asset_check import * +from .data_masking import * \ No newline at end of file diff --git a/apps/acls/api/data_masking.py b/apps/acls/api/data_masking.py new file mode 100644 index 000000000..2d45071b0 --- /dev/null +++ b/apps/acls/api/data_masking.py @@ -0,0 +1,26 @@ +from common.api import JMSBulkModelViewSet + +from orgs.utils import tmp_to_root_org +from .common import ACLUserFilterMixin +from ..models import DataMaskingRule +from .. import serializers + + +__all__ = ['DataMaskingRuleViewSet'] + + +class DataMaskingRuleFilter(ACLUserFilterMixin): + class Meta: + model = DataMaskingRule + fields = ('name', 'action') + + +class DataMaskingRuleViewSet(JMSBulkModelViewSet): + queryset = DataMaskingRule.objects.all() + filterset_class = DataMaskingRuleFilter + search_fields = ('name',) + serializer_class = serializers.DataMaskingRuleSerializer + + def filter_queryset(self, queryset): + with tmp_to_root_org(): + return super().filter_queryset(queryset) diff --git a/apps/acls/migrations/0003_datamaskingrule.py b/apps/acls/migrations/0003_datamaskingrule.py new file mode 100644 index 000000000..300b1a412 --- /dev/null +++ b/apps/acls/migrations/0003_datamaskingrule.py @@ -0,0 +1,45 @@ +# Generated by Django 4.1.13 on 2025-10-07 16:16 + +import common.db.fields +from django.conf import settings +import django.core.validators +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('acls', '0002_auto_20210926_1047'), + ] + + operations = [ + migrations.CreateModel( + name='DataMaskingRule', + 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')), + ('priority', models.IntegerField(default=50, help_text='1-100, the lower the value will be match first', validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)], verbose_name='Priority')), + ('action', models.CharField(default='reject', max_length=64, verbose_name='Action')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('users', common.db.fields.JSONManyToManyField(default=dict, to='users.User', verbose_name='Users')), + ('assets', common.db.fields.JSONManyToManyField(default=dict, to='assets.Asset', verbose_name='Assets')), + ('accounts', models.JSONField(default=list, verbose_name='Accounts')), + ('name', models.CharField(max_length=128, verbose_name='Name')), + ('fields_pattern', models.CharField(default='password', max_length=128, verbose_name='Fields pattern')), + ('masking_method', models.CharField(choices=[('fixed_char', 'Fixed Character Replacement'), ('hide_middle', 'Hide Middle Characters'), ('keep_prefix', 'Keep Prefix Only'), ('keep_suffix', 'Keep Suffix Only')], default='fixed_char', max_length=32, verbose_name='Masking Method')), + ('mask_pattern', models.CharField(blank=True, default='######', max_length=128, null=True, verbose_name='Mask Pattern')), + ('reviewers', models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL, verbose_name='Reviewers')), + ], + options={ + 'verbose_name': 'Data Masking Rule', + 'unique_together': {('org_id', 'name')}, + }, + ), + ] diff --git a/apps/acls/models/__init__.py b/apps/acls/models/__init__.py index 28fe366b3..481b7e392 100644 --- a/apps/acls/models/__init__.py +++ b/apps/acls/models/__init__.py @@ -2,3 +2,4 @@ from .command_acl import * from .connect_method import * from .login_acl import * from .login_asset_acl import * +from .data_masking import * \ No newline at end of file diff --git a/apps/acls/models/data_masking.py b/apps/acls/models/data_masking.py new file mode 100644 index 000000000..3d859e9b0 --- /dev/null +++ b/apps/acls/models/data_masking.py @@ -0,0 +1,42 @@ +from django.db import models + +from acls.models import UserAssetAccountBaseACL +from common.utils import get_logger +from django.utils.translation import gettext_lazy as _ + +logger = get_logger(__file__) + +__all__ = ['MaskingMethod', 'DataMaskingRule'] + + +class MaskingMethod(models.TextChoices): + fixed_char = "fixed_char", _("Fixed Character Replacement") # 固定字符替换 + hide_middle = "hide_middle", _("Hide Middle Characters") # 隐藏中间几位 + keep_prefix = "keep_prefix", _("Keep Prefix Only") # 只保留前缀 + keep_suffix = "keep_suffix", _("Keep Suffix Only") # 只保留后缀 + + +class DataMaskingRule(UserAssetAccountBaseACL): + name = models.CharField(max_length=128, verbose_name=_("Name")) + fields_pattern = models.CharField(max_length=128, default='password', verbose_name=_("Fields pattern")) + + masking_method = models.CharField( + max_length=32, + choices=MaskingMethod.choices, + default=MaskingMethod.fixed_char, + verbose_name=_("Masking Method"), + ) + mask_pattern = models.CharField( + max_length=128, + verbose_name=_("Mask Pattern"), + default="######", + blank=True, + null=True, + ) + + def __str__(self): + return self.name + + class Meta: + unique_together = [('org_id', 'name')] + verbose_name = _("Data Masking Rule") diff --git a/apps/acls/serializers/__init__.py b/apps/acls/serializers/__init__.py index d3fa9cdc6..678e79a41 100644 --- a/apps/acls/serializers/__init__.py +++ b/apps/acls/serializers/__init__.py @@ -3,3 +3,4 @@ from .connect_method import * from .login_acl import * from .login_asset_acl import * from .login_asset_check import * +from .data_masking import * \ No newline at end of file diff --git a/apps/acls/serializers/data_masking.py b/apps/acls/serializers/data_masking.py new file mode 100644 index 000000000..987416698 --- /dev/null +++ b/apps/acls/serializers/data_masking.py @@ -0,0 +1,18 @@ +from common.serializers.fields import LabeledChoiceField +from .base import BaseUserAssetAccountACLSerializer as BaseSerializer +from common.serializers.mixin import CommonBulkModelSerializer +from ..models import DataMaskingRule + +__all__ = ['DataMaskingRuleSerializer'] + +from ..models.data_masking import MaskingMethod + + +class DataMaskingRuleSerializer(BaseSerializer, CommonBulkModelSerializer): + masking_method = LabeledChoiceField( + choices=MaskingMethod, default=MaskingMethod.fixed_char, label='Masking Method' + ) + + class Meta(BaseSerializer.Meta): + model = DataMaskingRule + fields = BaseSerializer.Meta.fields + ['fields_pattern', 'masking_method', 'mask_pattern'] diff --git a/apps/acls/urls/api_urls.py b/apps/acls/urls/api_urls.py index 8c91698d8..64b1ba7ee 100644 --- a/apps/acls/urls/api_urls.py +++ b/apps/acls/urls/api_urls.py @@ -11,6 +11,7 @@ router.register(r'login-asset-acls', api.LoginAssetACLViewSet, 'login-asset-acl' router.register(r'command-filter-acls', api.CommandFilterACLViewSet, 'command-filter-acl') router.register(r'command-groups', api.CommandGroupViewSet, 'command-group') router.register(r'connect-method-acls', api.ConnectMethodACLViewSet, 'connect-method-acl') +router.register(r'data-masking-rules', api.DataMaskingRuleViewSet, 'data-masking-rule') urlpatterns = [ path('login-asset/check/', api.LoginAssetCheckAPI.as_view(), name='login-asset-check'), diff --git a/apps/authentication/models/connection_token.py b/apps/authentication/models/connection_token.py index 5c79c70db..f2bca5550 100644 --- a/apps/authentication/models/connection_token.py +++ b/apps/authentication/models/connection_token.py @@ -338,6 +338,18 @@ class ConnectionToken(JMSOrgBaseModel): acls = CommandFilterACL.filter_queryset(**kwargs).valid() return acls + @lazyproperty + def data_masking_rules(self): + from acls.models import DataMaskingRule + kwargs = { + 'user': self.user, + 'asset': self.asset, + 'account': self.account_object, + } + with tmp_to_org(self.asset.org_id): + rules = DataMaskingRule.filter_queryset(**kwargs).valid() + return rules + class SuperConnectionToken(ConnectionToken): _type = ConnectionTokenType.SUPER diff --git a/apps/authentication/serializers/connect_token_secret.py b/apps/authentication/serializers/connect_token_secret.py index 63357c55c..01d9c8ac8 100644 --- a/apps/authentication/serializers/connect_token_secret.py +++ b/apps/authentication/serializers/connect_token_secret.py @@ -3,7 +3,7 @@ from rest_framework import serializers from accounts.const import SecretType from accounts.models import Account -from acls.models import CommandGroup, CommandFilterACL +from acls.models import CommandGroup, CommandFilterACL, DataMaskingRule from assets.models import Asset, Platform, Gateway, Zone from assets.serializers.asset import AssetProtocolsSerializer from assets.serializers.platform import PlatformSerializer @@ -83,6 +83,14 @@ class _ConnectionTokenGatewaySerializer(serializers.ModelSerializer): ] +class _ConnectionTokenDataMaskingRuleSerializer(serializers.ModelSerializer): + class Meta: + model = DataMaskingRule + fields = ['id', 'name', 'fields_pattern', + 'masking_method', 'mask_pattern', + 'is_active', 'priority'] + + class _ConnectionTokenCommandFilterACLSerializer(serializers.ModelSerializer): command_groups = ObjectRelatedField( many=True, required=False, queryset=CommandGroup.objects, @@ -105,7 +113,7 @@ class _ConnectionTokenPlatformSerializer(PlatformSerializer): class Meta(PlatformSerializer.Meta): model = Platform fields = [field for field in PlatformSerializer.Meta.fields - if field not in PlatformSerializer.Meta.fields_m2m] + if field not in PlatformSerializer.Meta.fields_m2m] def get_field_names(self, declared_fields, info): names = super().get_field_names(declared_fields, info) @@ -139,6 +147,7 @@ class ConnectionTokenSecretSerializer(OrgResourceModelSerializerMixin): platform = _ConnectionTokenPlatformSerializer(read_only=True) zone = ObjectRelatedField(queryset=Zone.objects, required=False, label=_('Domain')) command_filter_acls = _ConnectionTokenCommandFilterACLSerializer(read_only=True, many=True) + data_masking_rules = _ConnectionTokenDataMaskingRuleSerializer(read_only=True, many=True) expire_now = serializers.BooleanField(label=_('Expired now'), write_only=True, default=True) connect_method = _ConnectTokenConnectMethodSerializer(read_only=True, source='connect_method_object') connect_options = serializers.JSONField(read_only=True) @@ -149,7 +158,7 @@ class ConnectionTokenSecretSerializer(OrgResourceModelSerializerMixin): model = ConnectionToken fields = [ 'id', 'value', 'user', 'asset', 'account', - 'platform', 'command_filter_acls', 'protocol', + 'platform', 'command_filter_acls', 'data_masking_rules', 'protocol', 'zone', 'gateway', 'actions', 'expire_at', 'from_ticket', 'expire_now', 'connect_method', 'connect_options', 'face_monitor_token'