feat: права и инвайты

This commit is contained in:
Artem Tsyrulnikov
2025-12-14 13:49:25 +03:00
parent f3e09a08eb
commit 4c3873c727
43 changed files with 1111 additions and 127 deletions

View File

@@ -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,
)

View File

@@ -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')

View File

@@ -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'

View 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,
)