feat: granular RBAC system with user groups, deny-list, permission registry, system-admin, self-mod prevention

This commit is contained in:
Agent Zero
2026-07-15 21:59:45 +02:00
parent 905bc9b744
commit b490a62322
17 changed files with 1408 additions and 13 deletions
+1
View File
@@ -116,6 +116,7 @@ class AuthService:
"email": session_data["email"],
"name": session_data["name"],
"role": session_data["role"],
"is_system_admin": session_data.get("is_system_admin", False),
"tenant_id": session_data["tenant_id"],
"tenant_name": tenant.name if tenant else None,
}
+245
View File
@@ -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()