feat(ram): RAM 资源访问管理——RAMGrant 模型 + RAMManager 服务层(GrantAccess/CheckAccess/Revoke/List)

This commit is contained in:
TermiNexus committed 2026-08-13 23:55:53 +08:00
1 parent 3f9d106a95
commit e2cafd5fcd
4 files changed
+319 -3

No files matched your search

+4 -2
View File
@@ -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'),
+6 -1
View File
@@ -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)
+261
View File
@@ -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)
+48
View File
@@ -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):