From e2cafd5fcd397f76d718b0e2e9a053b45b05c999 Mon Sep 17 00:00:00 2001 From: TermiNexus Date: Thu, 13 Aug 2026 23:55:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(ram):=20RAM=20=E8=B5=84=E6=BA=90=E8=AE=BF?= =?UTF-8?q?=E9=97=AE=E7=AE=A1=E7=90=86=E2=80=94=E2=80=94RAMGrant=20?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=20+=20RAMManager=20=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E5=B1=82=EF=BC=88GrantAccess/CheckAccess/Revoke/List=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- __init__.py | 6 +- auth/__init__.py | 7 +- auth/ram.py | 261 +++++++++++++++++++++++++++++++++++++++++++++++ models.py | 48 +++++++++ 4 files changed, 319 insertions(+), 3 deletions(-) create mode 100644 auth/ram.py diff --git a/__init__.py b/__init__.py index dd37c36..8ee7999 100644 --- a/__init__.py +++ b/__init__.py @@ -228,7 +228,8 @@ _LAZY_SYMBOLS = { 'CompanyManager': ('auth', 'CompanyManager'), 'DepartmentManager': ('auth', 'DepartmentManager'), 'MessageManager': ('auth', 'MessageManager'), - 'UserService': ('auth', 'UserService'), + 'UserService': ('auth', 'UserService'), + 'RAMManager': ('auth', 'RAMManager'), # ── 分佣引擎 ────────────────────────────────────────── 'CommissionEngine': ('commission', 'CommissionEngine'), @@ -294,7 +295,8 @@ _LAZY_SYMBOLS = { 'UserAvatar': ('models', 'UserAvatar'), 'UserLoginLog': ('models', 'UserLoginLog'), 'AccountActivation': ('models', 'AccountActivation'), - 'RolePermission': ('models', 'RolePermission'), + 'RolePermission': ('models', 'RolePermission'), + 'RAMGrant': ('models', 'RAMGrant'), 'UserABAC': ('models', 'UserABAC'), 'Password': ('models', 'Password'), 'Shop': ('models', 'Shop'), diff --git a/auth/__init__.py b/auth/__init__.py index 40dca92..024eef8 100644 --- a/auth/__init__.py +++ b/auth/__init__.py @@ -6,7 +6,7 @@ __all__ = [ 'IsAdminUser', 'IsCompanyMember', 'CompanyScopedMixin', 'TenantScopedMixin', 'UserScopedMixin', 'PermissionVerifier', 'IsAdminScopeDisabled', 'IsTenantScopeDisabled', 'IsAdmin', 'IsSuperAdmin', 'IsTenantAdmin', 'IsCompanyAdmin', - 'RoleManager', 'ShopManager', 'PositionManager', 'AuthManager', 'AdminUserManager', 'TenantManager', 'CompanyManager', 'DepartmentManager', 'MessageManager', 'UserService', + 'RoleManager', 'ShopManager', 'PositionManager', 'AuthManager', 'AdminUserManager', 'TenantManager', 'CompanyManager', 'DepartmentManager', 'MessageManager', 'UserService', 'RAMManager', ] @@ -36,6 +36,11 @@ def __getattr__(name): val = getattr(_m, name) globals()[name] = val return val + if name == 'RAMManager': + from gvsdsdk.auth import ram as _m + val = getattr(_m, name) + globals()[name] = val + return val if name == 'UserService': from gvsdsdk.auth import user_service as _m val = getattr(_m, name) diff --git a/auth/ram.py b/auth/ram.py new file mode 100644 index 0000000..a6fe6bb --- /dev/null +++ b/auth/ram.py @@ -0,0 +1,261 @@ +"""模块:RAM 资源访问管理(Resource Access Management)服务层 + +RAM 是统一"主体→资源→操作"授权模型(借鉴 AWS RAM/IAM),与现有 RBAC 融合: + + 主体 Principal :Role(角色)/ User(用户) + 资源 Resource :app(应用)/ menu(菜单)/ data_scope(数据权限,预留) + 操作 Action :view(可见)/ use(使用)/ manage(管理)/ grant(授权) + 作用域 Scope :授权只在归属租户/公司内生效(null=全租户/全公司,仅 super 可授) + +与现有权限体系融合: + - App 可见性 = 安装状态(AppManager.CheckAccess)∩ RAM(角色→App, view) + - 菜单可见性 = 公司过滤 ∧ 权限码校验 ∧ 应用安装 ∧ RAM(角色→Menu, view)(可选启用) + - 数据权限暂不接管(RoleScope 四级已实现),预留 ResourceType=data_scope + +判定优先级(与现有体系一致): + - 管理员(admin:super/tenant/company)→ True(豁免 RAM 校验) + - 其余按 RAMGrant 命中(GrantStatus=1 + 作用域匹配) +""" +import logging +import uuid as _uuid + +from gvsdsdk.models import RAMGrant +from gvsdsdk.model_utils import UUIDObj + +logger = logging.getLogger(__name__) + +# === 资源类型 === +RESOURCE_APP = 'app' # 应用(AppUUID) +RESOURCE_MENU = 'menu' # 菜单(MenuUUID) +RESOURCE_DATA_SCOPE = 'data_scope' # 数据权限(预留,暂不接管) + +# === 操作 === +ACTION_VIEW = 'view' # 可见(菜单注入/列表可见) +ACTION_USE = 'use' # 使用(打开应用/调用 API) +ACTION_MANAGE = 'manage' # 管理(安装/配置) +ACTION_GRANT = 'grant' # 授权(分发 RAM 授权) + +# === 主体类型 === +PRINCIPAL_ROLE = 'role' # 角色 +PRINCIPAL_USER = 'user' # 用户 + + +def _ToBytes(value): + """统一转 bytes(兼容 bytes/UUIDObj/uuid.UUID/str)""" + if value is None: + return None + if isinstance(value, bytes): + return value + if hasattr(value, 'bytes'): + return value.bytes + if isinstance(value, str): + try: + return UUIDObj(value).bytes + except Exception: + return None + return None + + +class RAMManager: + """RAM 授权统一服务层""" + + # ==================== 授权 ==================== + + @staticmethod + def GrantAccess(PrincipalType, PrincipalUUID, ResourceType, ResourceUUID, + Action, TenantUUID=None, CompanyUUID=None, GrantBy=None): + """授予访问权限(幂等 upsert:已存在同组合则激活,不存在则创建) + + Args: + PrincipalType: 主体类型(role/user) + PrincipalUUID: 主体 UUID(角色或用户) + ResourceType: 资源类型(app/menu/data_scope) + ResourceUUID: 资源 UUID + Action: 操作(view/use/manage/grant) + TenantUUID: 归属租户(None=跨租户授权,仅 super 可用) + CompanyUUID: 归属公司(None=租户内全部公司) + GrantBy: 授权人 UUID(可选) + + Returns: + (grant_record, err):err 非 None 表示失败 + """ + principal_bytes = _ToBytes(PrincipalUUID) + resource_bytes = _ToBytes(ResourceUUID) + tenant_bytes = _ToBytes(TenantUUID) + company_bytes = _ToBytes(CompanyUUID) + grant_by_bytes = _ToBytes(GrantBy) + if not principal_bytes or not resource_bytes: + return None, '主体/资源 UUID 无效' + if PrincipalType not in (PRINCIPAL_ROLE, PRINCIPAL_USER): + return None, f'主体类型无效: {PrincipalType}' + if ResourceType not in (RESOURCE_APP, RESOURCE_MENU, RESOURCE_DATA_SCOPE): + return None, f'资源类型无效: {ResourceType}' + if Action not in (ACTION_VIEW, ACTION_USE, ACTION_MANAGE, ACTION_GRANT): + return None, f'操作无效: {Action}' + + grant, created = RAMGrant.objects.get_or_create( + PrincipalType=PrincipalType, + PrincipalUUID=principal_bytes, + ResourceType=ResourceType, + ResourceUUID=resource_bytes, + Action=Action, + TenantUUID=tenant_bytes, + CompanyUUID=company_bytes, + defaults={ + 'GrantUUID': _uuid.uuid4().bytes, + 'GrantStatus': 1, + 'GrantBy': grant_by_bytes, + }, + ) + if not created: + if grant.GrantStatus != 1: + grant.GrantStatus = 1 + if grant_by_bytes: + grant.GrantBy = grant_by_bytes + grant.save(update_fields=['GrantStatus', 'GrantBy']) + elif grant_by_bytes: + grant.GrantBy = grant_by_bytes + grant.save(update_fields=['GrantBy']) + return grant, '' + + @staticmethod + def RevokeAccess(GrantUUID): + """撤销授权(按授权 UUID)""" + grant = RAMGrant.objects.filter(GrantUUID=_ToBytes(GrantUUID)).first() + if grant is None: + return False, '授权记录不存在' + grant.GrantStatus = 0 + grant.save(update_fields=['GrantStatus']) + return True, '' + + @staticmethod + def RevokeByResource(ResourceType, ResourceUUID, Action=None, PrincipalUUID=None): + """按资源撤销全部/部分授权(如卸载应用时清理 RAM 授权)""" + qs = RAMGrant.objects.filter(ResourceType=ResourceType, ResourceUUID=_ToBytes(ResourceUUID)) + if Action: + qs = qs.filter(Action=Action) + if PrincipalUUID: + qs = qs.filter(PrincipalUUID=_ToBytes(PrincipalUUID)) + count = qs.update(GrantStatus=0) + return count + + # ==================== 校验 ==================== + + @staticmethod + def CheckAccess(PrincipalUUID, ResourceType, ResourceUUID, Action, + TenantUUID=None, CompanyUUID=None): + """校验主体是否对资源拥有指定操作(GrantStatus=1 + 作用域匹配) + + 作用域规则: + - 授权 TenantUUID 为 None(跨租户)→ 命中 + - 授权 TenantUUID 等于请求租户 → 继续匹配公司 + - 公司:授权 CompanyUUID 为 None(租户内全公司)→ 命中;等于请求公司 → 命中 + + Returns: + bool + """ + principal_bytes = _ToBytes(PrincipalUUID) + resource_bytes = _ToBytes(ResourceUUID) + tenant_bytes = _ToBytes(TenantUUID) + company_bytes = _ToBytes(CompanyUUID) + if not principal_bytes or not resource_bytes: + return False + + qs = RAMGrant.objects.filter( + PrincipalUUID=principal_bytes, + ResourceType=ResourceType, + ResourceUUID=resource_bytes, + Action=Action, + GrantStatus=1, + ) + if tenant_bytes is not None: + qs = qs.filter(_TenantScopeQ(tenant_bytes)) + if company_bytes is not None: + qs = qs.filter(_CompanyScopeQ(company_bytes)) + return qs.exists() + + # ==================== 查询 ==================== + + @staticmethod + def ListGrants(ResourceType=None, ResourceUUID=None, PrincipalType=None, + PrincipalUUID=None, Action=None, Status=1, TenantUUID=None, + CompanyUUID=None): + """列出授权记录(灵活过滤,返回 dict 列表) + + Returns: + list[dict]:{GrantUUID, PrincipalType, PrincipalUUID, ResourceType, + ResourceUUID, Action, TenantUUID, CompanyUUID, GrantStatus, + GrantBy, GrantTime} + """ + qs = RAMGrant.objects.all() + if Status is not None: + qs = qs.filter(GrantStatus=Status) + if ResourceType: + qs = qs.filter(ResourceType=ResourceType) + if ResourceUUID: + qs = qs.filter(ResourceUUID=_ToBytes(ResourceUUID)) + if PrincipalType: + qs = qs.filter(PrincipalType=PrincipalType) + if PrincipalUUID: + qs = qs.filter(PrincipalUUID=_ToBytes(PrincipalUUID)) + if Action: + qs = qs.filter(Action=Action) + if TenantUUID: + qs = qs.filter(TenantUUID=_ToBytes(TenantUUID)) + if CompanyUUID: + qs = qs.filter(CompanyUUID=_ToBytes(CompanyUUID)) + return [RAMManager._ToFlat(g) for g in qs] + + @staticmethod + def ListResourcesForPrincipal(PrincipalUUID, ResourceType, Action, + TenantUUID=None, CompanyUUID=None): + """列出主体可访问的资源 UUID 集合(bytes 列表) + + 返回满足"作用域匹配 + GrantStatus=1"的资源 UUID bytes 列表, + 供菜单注入/应用可见性批量判定使用(一次查询取集合,避免 N+1)。 + """ + principal_bytes = _ToBytes(PrincipalUUID) + if not principal_bytes: + return [] + qs = RAMGrant.objects.filter( + PrincipalUUID=principal_bytes, + ResourceType=ResourceType, + Action=Action, + GrantStatus=1, + ) + if TenantUUID is not None: + qs = qs.filter(_TenantScopeQ(_ToBytes(TenantUUID))) + if CompanyUUID is not None: + qs = qs.filter(_CompanyScopeQ(_ToBytes(CompanyUUID))) + return list(qs.values_list('ResourceUUID', flat=True)) + + # ==================== 内部 ==================== + + @staticmethod + def _ToFlat(grant): + """RAMGrant 实例 → dict""" + return { + 'GrantUUID': UUIDObj(grant.GrantUUID).str if grant.GrantUUID else '', + 'PrincipalType': grant.PrincipalType, + 'PrincipalUUID': UUIDObj(grant.PrincipalUUID).str if grant.PrincipalUUID else '', + 'ResourceType': grant.ResourceType, + 'ResourceUUID': UUIDObj(grant.ResourceUUID).str if grant.ResourceUUID else '', + 'Action': grant.Action, + 'TenantUUID': UUIDObj(grant.TenantUUID).str if grant.TenantUUID else '', + 'CompanyUUID': UUIDObj(grant.CompanyUUID).str if grant.CompanyUUID else '', + 'GrantStatus': grant.GrantStatus, + 'GrantBy': UUIDObj(grant.GrantBy).str if grant.GrantBy else '', + 'GrantTime': grant.GrantTime.isoformat() if grant.GrantTime else None, + } + + +def _TenantScopeQ(tenant_bytes): + """租户作用域过滤:授权跨租户(TenantUUID 空)或匹配当前租户""" + from django.db.models import Q + return Q(TenantUUID__isnull=True) | Q(TenantUUID=tenant_bytes) + + +def _CompanyScopeQ(company_bytes): + """公司作用域过滤:授权租户内全公司(CompanyUUID 空)或匹配当前公司""" + from django.db.models import Q + return Q(CompanyUUID__isnull=True) | Q(CompanyUUID=company_bytes) diff --git a/models.py b/models.py index d08e313..0683abe 100644 --- a/models.py +++ b/models.py @@ -796,6 +796,54 @@ class RolePermission(QModel): verbose_name_plural = '角色权限关联' +# === RAM 资源访问管理表 === + +class RAMGrant(QModel): + """RAM 授权表——资源访问管理(Resource Access Management)授权记录 + + RAM 是统一"主体→资源→操作"授权模型(借鉴 AWS RAM/IAM): + - 主体 Principal:Role(角色)/ User(用户) + - 资源 Resource:App(应用)/ Menu(菜单)/ data_scope(数据权限,预留) + - 操作 Action:view(可见)/ use(使用)/ manage(管理)/ grant(授权) + - 作用域 Scope:授权只在归属租户/公司内生效(null=全租户/全公司,仅 super 可授) + + 与现有权限体系融合: + - App 可见性 = 安装状态(AppManager.CheckAccess)∩ RAM(角色→App, view) + - 菜单可见性 = 公司过滤 ∧ 权限码校验 ∧ 应用安装 ∧ RAM(角色→Menu, view)(可选启用) + - 数据权限暂不接管(RoleScope 四级已实现),预留 ResourceType=data_scope + """ + + GrantUUID = UUIDField(primary_key=True, db_column='GrantUUID', verbose_name='授权UUID') + PrincipalType = models.CharField(max_length=16, db_column='PrincipalType', verbose_name='主体类型(role/user)') + PrincipalUUID = UUIDField(db_column='PrincipalUUID', verbose_name='主体UUID(角色/用户)') + ResourceType = models.CharField(max_length=32, db_column='ResourceType', verbose_name='资源类型(app/menu/data_scope)') + ResourceUUID = UUIDField(db_column='ResourceUUID', verbose_name='资源UUID') + Action = models.CharField(max_length=16, db_column='Action', verbose_name='操作(view/use/manage/grant)') + TenantUUID = UUIDField(null=True, blank=True, db_column='TenantUUID', verbose_name='归属租户(null=跨租户)') + CompanyUUID = UUIDField(null=True, blank=True, db_column='CompanyUUID', verbose_name='归属公司(null=租户内全部公司)') + GrantStatus = models.SmallIntegerField(default=1, db_column='GrantStatus', verbose_name='状态(1生效/0撤销)') + GrantBy = UUIDField(null=True, blank=True, db_column='GrantBy', verbose_name='授权人UUID') + GrantTime = models.DateTimeField(default=_UtcNow, verbose_name='授权时间') + + class Meta: + managed = False + app_label = 'gvsdsdk' + db_table = 'RAMGrant' + verbose_name = 'RAM授权' + verbose_name_plural = 'RAM授权' + unique_together = [ + ('PrincipalType', 'PrincipalUUID', 'ResourceType', 'ResourceUUID', + 'Action', 'TenantUUID', 'CompanyUUID'), + ] + indexes = [ + models.Index(fields=['PrincipalUUID', 'ResourceType'], name='idx_ram_principal'), + models.Index(fields=['ResourceType', 'ResourceUUID'], name='idx_ram_resource'), + ] + + def __str__(self): + return f'{self.PrincipalType}:{self.PrincipalUUID} {self.Action} {self.ResourceType}:{self.ResourceUUID}' + + # === 用户 ABAC 表 === class UserABAC(QModel):