feat: granular RBAC system with user groups, deny-list, permission registry, system-admin, self-mod prevention
This commit is contained in:
@@ -17,6 +17,7 @@ from app.schemas.auth import (
|
||||
PasswordResetRequest,
|
||||
SwitchTenantRequest,
|
||||
)
|
||||
from app.deps import get_current_user
|
||||
from app.services.auth_service import auth_service
|
||||
|
||||
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||
@@ -72,6 +73,7 @@ async def login(
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"role": user.role,
|
||||
"is_system_admin": user.is_system_admin,
|
||||
"tenant_id": str(tenant.id),
|
||||
"tenant_name": tenant.name,
|
||||
"csrf_token": csrf_token,
|
||||
@@ -134,6 +136,25 @@ async def me(
|
||||
return info
|
||||
|
||||
|
||||
@router.get("/me/permissions")
|
||||
async def me_permissions(
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Get resolved permissions for the current user.
|
||||
|
||||
Returns permissions, denied_permissions, field_permissions, and is_system_admin.
|
||||
Used by frontend to enable/disable UI elements based on permissions.
|
||||
"""
|
||||
return {
|
||||
"permissions": current_user.get("permissions", []),
|
||||
"denied_permissions": current_user.get("denied_permissions", []),
|
||||
"field_permissions": current_user.get("field_permissions", {}),
|
||||
"is_system_admin": current_user.get("is_system_admin", False),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/switch-tenant")
|
||||
async def switch_tenant(
|
||||
request: Request,
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
"""Group management routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.audit import log_audit
|
||||
from app.core.auth import get_redis
|
||||
from app.core.db import get_db
|
||||
from app.core.permissions import invalidate_all_user_permissions
|
||||
from app.deps import get_current_user, require_permission
|
||||
from app.schemas.group import AddMemberRequest, GroupCreate, GroupUpdate
|
||||
from app.services.group_service import group_service
|
||||
|
||||
router = APIRouter(prefix="/api/v1/groups", tags=["groups"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_groups(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""List all groups in the current tenant."""
|
||||
import uuid as _uuid
|
||||
|
||||
tenant_id = _uuid.UUID(current_user["tenant_id"])
|
||||
return {"items": await group_service.list_groups(db, tenant_id)}
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def create_group(
|
||||
body: GroupCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("groups:write")),
|
||||
):
|
||||
"""Create a new group."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
acting_user_id = uuid.UUID(current_user["user_id"])
|
||||
|
||||
group = await group_service.create_group(
|
||||
db,
|
||||
tenant_id,
|
||||
body.name,
|
||||
body.description,
|
||||
body.permissions,
|
||||
body.denied_permissions,
|
||||
body.field_permissions,
|
||||
)
|
||||
|
||||
await log_audit(
|
||||
db, tenant_id, acting_user_id, "create", "group", group.id,
|
||||
changes={"name": body.name},
|
||||
)
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{group_id}")
|
||||
async def get_group(
|
||||
group_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Get a single group."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
try:
|
||||
gid = uuid.UUID(group_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail={"detail": "Invalid group_id", "code": "invalid_id"}) from None
|
||||
|
||||
group = await group_service.get_group(db, tenant_id, gid)
|
||||
if group is None:
|
||||
raise HTTPException(404, detail={"detail": "Group not found", "code": "not_found"})
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/{group_id}")
|
||||
async def update_group(
|
||||
group_id: str,
|
||||
body: GroupUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("groups:write")),
|
||||
):
|
||||
"""Update a group."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
acting_user_id = uuid.UUID(current_user["user_id"])
|
||||
try:
|
||||
gid = uuid.UUID(group_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail={"detail": "Invalid group_id", "code": "invalid_id"}) from None
|
||||
|
||||
group = await group_service.update_group(
|
||||
db, tenant_id, gid,
|
||||
body.name, body.description,
|
||||
body.permissions, body.denied_permissions, body.field_permissions,
|
||||
)
|
||||
if group is None:
|
||||
raise HTTPException(404, detail={"detail": "Group not found", "code": "not_found"})
|
||||
|
||||
# Invalidate permission cache for all users in this tenant
|
||||
redis = get_redis()
|
||||
await invalidate_all_user_permissions(redis, tenant_id)
|
||||
|
||||
await log_audit(
|
||||
db, tenant_id, acting_user_id, "update", "group", gid,
|
||||
changes=body.model_dump(exclude_none=True),
|
||||
)
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_group(
|
||||
group_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("groups:delete")),
|
||||
):
|
||||
"""Delete a group."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
acting_user_id = uuid.UUID(current_user["user_id"])
|
||||
try:
|
||||
gid = uuid.UUID(group_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail={"detail": "Invalid group_id", "code": "invalid_id"}) from None
|
||||
|
||||
success = await group_service.delete_group(db, tenant_id, gid)
|
||||
if not success:
|
||||
raise HTTPException(404, detail={"detail": "Group not found", "code": "not_found"})
|
||||
|
||||
# Invalidate permission cache
|
||||
redis = get_redis()
|
||||
await invalidate_all_user_permissions(redis, tenant_id)
|
||||
|
||||
await log_audit(
|
||||
db, tenant_id, acting_user_id, "delete", "group", gid,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{group_id}/members")
|
||||
async def list_group_members(
|
||||
group_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""List all members of a group."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
try:
|
||||
gid = uuid.UUID(group_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail={"detail": "Invalid group_id", "code": "invalid_id"}) from None
|
||||
|
||||
members = await group_service.get_group_members(db, tenant_id, gid)
|
||||
return {"items": members}
|
||||
|
||||
|
||||
@router.post("/{group_id}/members")
|
||||
async def add_group_member(
|
||||
group_id: str,
|
||||
body: AddMemberRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("groups:write")),
|
||||
):
|
||||
"""Add a user to a group."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
acting_user_id = uuid.UUID(current_user["user_id"])
|
||||
try:
|
||||
gid = uuid.UUID(group_id)
|
||||
uid = uuid.UUID(body.user_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail={"detail": "Invalid ID", "code": "invalid_id"}) from None
|
||||
|
||||
success = await group_service.add_user_to_group(db, tenant_id, gid, uid)
|
||||
if not success:
|
||||
raise HTTPException(404, detail={"detail": "Group or user not found in tenant", "code": "not_found"})
|
||||
|
||||
# Invalidate permission cache for this user
|
||||
redis = get_redis()
|
||||
from app.core.permissions import invalidate_permission_cache
|
||||
await invalidate_permission_cache(redis, uid, tenant_id)
|
||||
|
||||
await log_audit(
|
||||
db, tenant_id, acting_user_id, "add_member", "group", gid,
|
||||
changes={"user_id": body.user_id},
|
||||
)
|
||||
|
||||
return {"message": "User added to group"}
|
||||
|
||||
|
||||
@router.delete("/{group_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def remove_group_member(
|
||||
group_id: str,
|
||||
user_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("groups:write")),
|
||||
):
|
||||
"""Remove a user from a group."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
acting_user_id = uuid.UUID(current_user["user_id"])
|
||||
try:
|
||||
gid = uuid.UUID(group_id)
|
||||
uid = uuid.UUID(user_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail={"detail": "Invalid ID", "code": "invalid_id"}) from None
|
||||
|
||||
success = await group_service.remove_user_from_group(db, tenant_id, gid, uid)
|
||||
if not success:
|
||||
raise HTTPException(404, detail={"detail": "Membership not found", "code": "not_found"})
|
||||
|
||||
# Invalidate permission cache for this user
|
||||
redis = get_redis()
|
||||
from app.core.permissions import invalidate_permission_cache
|
||||
await invalidate_permission_cache(redis, uid, tenant_id)
|
||||
|
||||
await log_audit(
|
||||
db, tenant_id, acting_user_id, "remove_member", "group", gid,
|
||||
changes={"user_id": user_id},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/user/{user_id}")
|
||||
async def get_user_groups(
|
||||
user_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""List all groups a user is member of."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
try:
|
||||
uid = uuid.UUID(user_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail={"detail": "Invalid user_id", "code": "invalid_id"}) from None
|
||||
|
||||
groups = await group_service.get_user_groups(db, tenant_id, uid)
|
||||
return {"items": groups}
|
||||
@@ -9,7 +9,10 @@ from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.audit import log_audit
|
||||
from app.core.auth import get_redis
|
||||
from app.core.db import get_db
|
||||
from app.core.permissions import invalidate_all_user_permissions
|
||||
from app.deps import get_current_user, require_admin
|
||||
from app.models.plugin import Plugin as PluginModel
|
||||
from app.plugins.registry import get_registry
|
||||
@@ -142,6 +145,17 @@ async def update_role(
|
||||
if role is None:
|
||||
raise HTTPException(404, detail={"detail": "Role not found", "code": "not_found"})
|
||||
|
||||
# Invalidate permission cache for all users in this tenant
|
||||
redis = get_redis()
|
||||
await invalidate_all_user_permissions(redis, tenant_id)
|
||||
|
||||
# Audit log
|
||||
acting_user_id = uuid.UUID(current_user["user_id"])
|
||||
await log_audit(
|
||||
db, tenant_id, acting_user_id, "update", "role", rid,
|
||||
changes=body.model_dump(exclude_none=True),
|
||||
)
|
||||
|
||||
return {
|
||||
"id": str(role.id),
|
||||
"name": role.name,
|
||||
@@ -169,4 +183,11 @@ async def delete_role(
|
||||
if not success:
|
||||
raise HTTPException(404, detail={"detail": "Role not found", "code": "not_found"})
|
||||
|
||||
# Invalidate permission cache for all users in this tenant
|
||||
redis = get_redis()
|
||||
await invalidate_all_user_permissions(redis, tenant_id)
|
||||
|
||||
acting_user_id = uuid.UUID(current_user["user_id"])
|
||||
await log_audit(db, tenant_id, acting_user_id, "delete", "role", rid)
|
||||
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -9,8 +9,10 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.audit import log_audit
|
||||
from app.core.auth import get_redis
|
||||
from app.core.db import get_db
|
||||
from app.core.notifications import create_notification
|
||||
from app.core.permissions import invalidate_permission_cache
|
||||
from app.deps import get_current_user, require_admin
|
||||
from app.schemas.user import UserCreate, UserUpdate
|
||||
from app.services.user_service import user_service, _UNSET
|
||||
@@ -143,6 +145,9 @@ async def update_user(
|
||||
Uses ``model_fields_set`` to detect whether ``role_id`` was explicitly
|
||||
present in the request body (even if sent as ``null``). This allows
|
||||
the caller to clear the FK by sending ``role_id: null``.
|
||||
|
||||
Self-modification prevention: a user cannot change their own role,
|
||||
is_active status, or system_admin flag.
|
||||
"""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
acting_user_id = uuid.UUID(current_user["user_id"])
|
||||
@@ -153,6 +158,14 @@ async def update_user(
|
||||
400, detail={"detail": "Invalid user_id", "code": "invalid_id"}
|
||||
) from None
|
||||
|
||||
# Self-modification prevention: cannot change own role or active status
|
||||
if uid == acting_user_id:
|
||||
if body.role is not None or body.is_active is not None or "role_id" in body.model_fields_set:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={"detail": "Cannot modify your own role or active status", "code": "self_modification_forbidden"},
|
||||
)
|
||||
|
||||
# Determine if role_id was explicitly sent (Pydantic v2)
|
||||
role_id_sent = "role_id" in body.model_fields_set
|
||||
|
||||
@@ -188,6 +201,10 @@ async def update_user(
|
||||
|
||||
await log_audit(db, tenant_id, acting_user_id, "update", "user", uid, changes=changes)
|
||||
|
||||
# Invalidate permission cache for the updated user
|
||||
redis = get_redis()
|
||||
await invalidate_permission_cache(redis, uid, tenant_id)
|
||||
|
||||
return {
|
||||
"id": str(user.id),
|
||||
"email": user.email,
|
||||
|
||||
Reference in New Issue
Block a user