feat: права и инвайты
This commit is contained in:
@@ -7,6 +7,9 @@ __all__ = (
|
||||
'WorkspaceUserStatus',
|
||||
'WorkspaceUserPermission',
|
||||
'WorkspaceUserPermissionScope',
|
||||
'WorkspacePermissions',
|
||||
'WorkspacePermissionContext',
|
||||
'build_workspace_permission_context',
|
||||
'PermissionKey',
|
||||
'PermissionScopeType',
|
||||
'Channel',
|
||||
@@ -34,6 +37,9 @@ __all__ = (
|
||||
'UserNotFound',
|
||||
'WorkspaceNotFound',
|
||||
'WorkspaceAccessDenied',
|
||||
'WorkspaceInviteNotFound',
|
||||
'WorkspaceInviteAlreadyExists',
|
||||
'WorkspaceMemberAlreadyExists',
|
||||
'LoginTokenNotFound',
|
||||
'LoginTokenExpired',
|
||||
'LoginTokenAlreadyUsed',
|
||||
@@ -46,6 +52,7 @@ __all__ = (
|
||||
'CreativeNotFound',
|
||||
'CreativeInUse',
|
||||
'PlacementNotFound',
|
||||
'UserByUsernameNotFound',
|
||||
)
|
||||
|
||||
from .channel import Channel, ChannelVerificationStatus
|
||||
@@ -63,8 +70,12 @@ from .error import (
|
||||
ProjectNotFound,
|
||||
PurchasePlanChannelNotFound,
|
||||
PurchasePlanNotFound,
|
||||
UserByUsernameNotFound,
|
||||
UserNotFound,
|
||||
WorkspaceAccessDenied,
|
||||
WorkspaceInviteAlreadyExists,
|
||||
WorkspaceInviteNotFound,
|
||||
WorkspaceMemberAlreadyExists,
|
||||
WorkspaceNotFound,
|
||||
)
|
||||
from .login_token import LoginToken
|
||||
@@ -91,3 +102,8 @@ from .workspace import (
|
||||
WorkspaceUserPermissionScope,
|
||||
WorkspaceUserStatus,
|
||||
)
|
||||
from .workspace_permissions import (
|
||||
WorkspacePermissionContext,
|
||||
WorkspacePermissions,
|
||||
build_workspace_permission_context,
|
||||
)
|
||||
|
||||
@@ -9,6 +9,10 @@ def UserNotFound(user_id: uuid.UUID | None = None) -> HTTPException:
|
||||
return HTTPException(status.HTTP_404_NOT_FOUND, f'User {user_id} not found')
|
||||
|
||||
|
||||
def UserByUsernameNotFound(username: str) -> HTTPException:
|
||||
return HTTPException(status.HTTP_404_NOT_FOUND, f'User @{username} not found')
|
||||
|
||||
|
||||
def LoginTokenNotFound() -> HTTPException:
|
||||
return HTTPException(status.HTTP_404_NOT_FOUND, 'Login token not found')
|
||||
|
||||
@@ -84,3 +88,17 @@ def WorkspaceAccessDenied(workspace_id: uuid.UUID | None = None) -> HTTPExceptio
|
||||
if workspace_id is None:
|
||||
return HTTPException(status.HTTP_403_FORBIDDEN, 'Workspace access denied')
|
||||
return HTTPException(status.HTTP_403_FORBIDDEN, f'Workspace {workspace_id} access denied')
|
||||
|
||||
|
||||
def WorkspaceInviteNotFound(invite_id: uuid.UUID | None = None) -> HTTPException:
|
||||
if invite_id is None:
|
||||
return HTTPException(status.HTTP_404_NOT_FOUND, 'Workspace invite not found')
|
||||
return HTTPException(status.HTTP_404_NOT_FOUND, f'Workspace invite {invite_id} not found')
|
||||
|
||||
|
||||
def WorkspaceInviteAlreadyExists() -> HTTPException:
|
||||
return HTTPException(status.HTTP_409_CONFLICT, 'Workspace invite already exists for this user')
|
||||
|
||||
|
||||
def WorkspaceMemberAlreadyExists() -> HTTPException:
|
||||
return HTTPException(status.HTTP_409_CONFLICT, 'User is already a workspace member')
|
||||
|
||||
@@ -66,18 +66,26 @@ class WorkspaceInvite(TimestampedModel):
|
||||
'models.User', related_name='workspace_invites', on_delete=fields.CASCADE, index=True
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
workspace_id: uuid.UUID
|
||||
invited_by_id: uuid.UUID
|
||||
user_id: uuid.UUID
|
||||
|
||||
class Meta:
|
||||
table = 'workspace_invite'
|
||||
unique_together = (('workspace_id', 'user_id'),)
|
||||
|
||||
|
||||
class PermissionKey(str, enum.Enum):
|
||||
MANAGE_PROJECTS = 'manage_projects'
|
||||
MANAGE_PLACEMENTS = 'manage_placements'
|
||||
VIEW_ANALYTICS = 'view_analytics'
|
||||
class PermissionKey(enum.StrEnum):
|
||||
PROJECTS_READ = 'projects_read'
|
||||
PROJECTS_WRITE = 'projects_write'
|
||||
PLACEMENTS_READ = 'placements_read'
|
||||
PLACEMENTS_WRITE = 'placements_write'
|
||||
ANALYTICS_READ = 'analytics_read'
|
||||
ADMIN_FULL = 'admin_full'
|
||||
|
||||
|
||||
class PermissionScopeType(str, enum.Enum):
|
||||
class PermissionScopeType(enum.StrEnum):
|
||||
WORKSPACE = 'workspace'
|
||||
PROJECT = 'project'
|
||||
|
||||
|
||||
102
src/domain/workspace_permissions.py
Normal file
102
src/domain/workspace_permissions.py
Normal file
@@ -0,0 +1,102 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from uuid import UUID
|
||||
|
||||
from . import WorkspaceAccessDenied
|
||||
from .workspace import (
|
||||
PermissionKey,
|
||||
PermissionScopeType,
|
||||
Workspace,
|
||||
WorkspaceUser,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkspacePermissions:
|
||||
global_permissions: set[PermissionKey]
|
||||
scoped_permissions: dict[tuple[PermissionKey, PermissionScopeType], set[UUID]]
|
||||
|
||||
@classmethod
|
||||
def from_membership(cls, membership: WorkspaceUser) -> WorkspacePermissions:
|
||||
global_permissions = {
|
||||
permission.permission
|
||||
for permission in getattr(membership, 'permissions', []) or []
|
||||
}
|
||||
|
||||
scoped_permissions: dict[tuple[PermissionKey, PermissionScopeType], set[UUID]] = {}
|
||||
for scope in getattr(membership, 'permission_scopes', []) or []:
|
||||
key = (scope.permission, scope.scope_type)
|
||||
scoped_permissions.setdefault(key, set()).add(scope.scope_id)
|
||||
|
||||
return cls(global_permissions=global_permissions, scoped_permissions=scoped_permissions)
|
||||
|
||||
def has_global(self, permission: PermissionKey) -> bool:
|
||||
return permission in self.global_permissions or PermissionKey.ADMIN_FULL in self.global_permissions
|
||||
|
||||
def allowed_project_ids(self, permission: PermissionKey) -> set[UUID] | None:
|
||||
if self.has_global(permission):
|
||||
return None
|
||||
|
||||
allowed: set[UUID] = set()
|
||||
for key in (permission, PermissionKey.ADMIN_FULL):
|
||||
project_scope = self.scoped_permissions.get((key, PermissionScopeType.PROJECT))
|
||||
if project_scope:
|
||||
allowed.update(project_scope)
|
||||
|
||||
return allowed
|
||||
|
||||
def has_permission(
|
||||
self,
|
||||
permission: PermissionKey,
|
||||
*,
|
||||
scope_type: PermissionScopeType | None = None,
|
||||
scope_id: UUID | None = None,
|
||||
) -> bool:
|
||||
if self.has_global(permission):
|
||||
return True
|
||||
|
||||
if scope_type == PermissionScopeType.PROJECT and scope_id is not None:
|
||||
allowed = self.allowed_project_ids(permission)
|
||||
if allowed is None:
|
||||
return True
|
||||
return scope_id in allowed
|
||||
|
||||
return False
|
||||
|
||||
def has_any(self, permission: PermissionKey) -> bool:
|
||||
allowed = self.allowed_project_ids(permission)
|
||||
if allowed is None:
|
||||
return True
|
||||
return bool(allowed)
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkspacePermissionContext:
|
||||
workspace: Workspace
|
||||
membership: WorkspaceUser
|
||||
permissions: WorkspacePermissions
|
||||
|
||||
def allowed_project_ids(self, permission: PermissionKey) -> set[UUID] | None:
|
||||
return self.permissions.allowed_project_ids(permission)
|
||||
|
||||
def ensure_project_permission(self, permission: PermissionKey, project_id: UUID) -> None:
|
||||
if not self.permissions.has_permission(
|
||||
permission,
|
||||
scope_type=PermissionScopeType.PROJECT,
|
||||
scope_id=project_id,
|
||||
):
|
||||
raise WorkspaceAccessDenied(self.workspace.id)
|
||||
|
||||
|
||||
def build_workspace_permission_context(membership: WorkspaceUser) -> WorkspacePermissionContext:
|
||||
if membership.workspace is None:
|
||||
raise ValueError('Workspace relation must be loaded for membership permissions')
|
||||
|
||||
permissions = WorkspacePermissions.from_membership(membership)
|
||||
|
||||
return WorkspacePermissionContext(
|
||||
workspace=membership.workspace,
|
||||
membership=membership,
|
||||
permissions=permissions,
|
||||
)
|
||||
Reference in New Issue
Block a user