feat: granular RBAC system with user groups, deny-list, permission registry, system-admin, self-mod prevention
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
"""Group management service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.permissions import invalidate_all_user_permissions
|
||||
from app.models.group import Group, UserGroup
|
||||
from app.models.user import User, UserTenant
|
||||
|
||||
|
||||
class GroupService:
|
||||
"""Handles group CRUD and user-group membership."""
|
||||
|
||||
async def list_groups(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List all groups in a tenant."""
|
||||
q = select(Group).where(Group.tenant_id == tenant_id, Group.deleted_at.is_(None))
|
||||
result = await db.execute(q)
|
||||
groups = result.scalars().all()
|
||||
return [self._group_to_dict(g) for g in groups]
|
||||
|
||||
async def get_group(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
group_id: uuid.UUID,
|
||||
) -> Group | None:
|
||||
"""Get a single group by ID within tenant scope."""
|
||||
q = select(Group).where(
|
||||
Group.id == group_id,
|
||||
Group.tenant_id == tenant_id,
|
||||
Group.deleted_at.is_(None),
|
||||
)
|
||||
result = await db.execute(q)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def create_group(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
name: str,
|
||||
description: str | None = None,
|
||||
permissions: dict[str, Any] | None = None,
|
||||
denied_permissions: list[str] | None = None,
|
||||
field_permissions: dict[str, Any] | None = None,
|
||||
) -> Group:
|
||||
"""Create a new group."""
|
||||
group = Group(
|
||||
tenant_id=tenant_id,
|
||||
name=name,
|
||||
description=description,
|
||||
permissions=permissions or {},
|
||||
denied_permissions=denied_permissions or [],
|
||||
field_permissions=field_permissions or {},
|
||||
)
|
||||
db.add(group)
|
||||
await db.flush()
|
||||
return group
|
||||
|
||||
async def update_group(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
group_id: uuid.UUID,
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
permissions: dict[str, Any] | None = None,
|
||||
denied_permissions: list[str] | None = None,
|
||||
field_permissions: dict[str, Any] | None = None,
|
||||
) -> Group | None:
|
||||
"""Update a group. Increments permission_version on permission changes."""
|
||||
q = select(Group).where(
|
||||
Group.id == group_id,
|
||||
Group.tenant_id == tenant_id,
|
||||
Group.deleted_at.is_(None),
|
||||
)
|
||||
result = await db.execute(q)
|
||||
group = result.scalar_one_or_none()
|
||||
if group is None:
|
||||
return None
|
||||
|
||||
version_bump = False
|
||||
if name is not None:
|
||||
group.name = name
|
||||
if description is not None:
|
||||
group.description = description
|
||||
if permissions is not None:
|
||||
group.permissions = permissions
|
||||
version_bump = True
|
||||
if denied_permissions is not None:
|
||||
group.denied_permissions = denied_permissions
|
||||
version_bump = True
|
||||
if field_permissions is not None:
|
||||
group.field_permissions = field_permissions
|
||||
version_bump = True
|
||||
|
||||
if version_bump:
|
||||
group.permission_version += 1
|
||||
|
||||
await db.flush()
|
||||
return group
|
||||
|
||||
async def delete_group(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
group_id: uuid.UUID,
|
||||
) -> bool:
|
||||
"""Soft-delete a group."""
|
||||
from datetime import UTC, datetime
|
||||
|
||||
q = select(Group).where(
|
||||
Group.id == group_id,
|
||||
Group.tenant_id == tenant_id,
|
||||
Group.deleted_at.is_(None),
|
||||
)
|
||||
result = await db.execute(q)
|
||||
group = result.scalar_one_or_none()
|
||||
if group is None:
|
||||
return False
|
||||
|
||||
group.deleted_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
return True
|
||||
|
||||
async def get_group_members(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
group_id: uuid.UUID,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List all users in a group."""
|
||||
q = (
|
||||
select(User, UserGroup)
|
||||
.join(User, UserGroup.user_id == User.id)
|
||||
.where(
|
||||
UserGroup.group_id == group_id,
|
||||
UserGroup.tenant_id == tenant_id,
|
||||
)
|
||||
)
|
||||
result = await db.execute(q)
|
||||
return [
|
||||
{
|
||||
"user_id": str(user.id),
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"is_active": user.is_active,
|
||||
}
|
||||
for user, _ug in result.all()
|
||||
]
|
||||
|
||||
async def add_user_to_group(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
group_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
) -> bool:
|
||||
"""Add a user to a group."""
|
||||
# Verify user is in this tenant
|
||||
ut_q = select(UserTenant).where(
|
||||
UserTenant.user_id == user_id,
|
||||
UserTenant.tenant_id == tenant_id,
|
||||
)
|
||||
ut_result = await db.execute(ut_q)
|
||||
if ut_result.scalar_one_or_none() is None:
|
||||
return False
|
||||
|
||||
# Check if already member
|
||||
existing_q = select(UserGroup).where(
|
||||
UserGroup.user_id == user_id,
|
||||
UserGroup.group_id == group_id,
|
||||
UserGroup.tenant_id == tenant_id,
|
||||
)
|
||||
existing_result = await db.execute(existing_q)
|
||||
if existing_result.scalar_one_or_none() is not None:
|
||||
return True # Already member
|
||||
|
||||
ug = UserGroup(
|
||||
user_id=user_id,
|
||||
group_id=group_id,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
db.add(ug)
|
||||
await db.flush()
|
||||
return True
|
||||
|
||||
async def remove_user_from_group(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
group_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
) -> bool:
|
||||
"""Remove a user from a group."""
|
||||
q = delete(UserGroup).where(
|
||||
UserGroup.user_id == user_id,
|
||||
UserGroup.group_id == group_id,
|
||||
UserGroup.tenant_id == tenant_id,
|
||||
)
|
||||
result = await db.execute(q)
|
||||
await db.flush()
|
||||
return result.rowcount > 0
|
||||
|
||||
async def get_user_groups(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List all groups a user is member of."""
|
||||
q = (
|
||||
select(Group)
|
||||
.join(UserGroup, UserGroup.group_id == Group.id)
|
||||
.where(
|
||||
UserGroup.user_id == user_id,
|
||||
UserGroup.tenant_id == tenant_id,
|
||||
Group.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
result = await db.execute(q)
|
||||
return [self._group_to_dict(g) for g in result.scalars().all()]
|
||||
|
||||
def _group_to_dict(self, group: Group) -> dict[str, Any]:
|
||||
"""Convert group to response dict."""
|
||||
return {
|
||||
"id": str(group.id),
|
||||
"name": group.name,
|
||||
"description": group.description,
|
||||
"permissions": group.permissions,
|
||||
"denied_permissions": group.denied_permissions,
|
||||
"field_permissions": group.field_permissions,
|
||||
"permission_version": group.permission_version,
|
||||
}
|
||||
|
||||
|
||||
group_service = GroupService()
|
||||
Reference in New Issue
Block a user