jogosultsági szintek RBAC beállítva tesztelve
This commit is contained in:
@@ -13,7 +13,6 @@ from app.models.identity import User, UserRole # JAVÍTVA: Új Identity modell
|
||||
from app.models.marketplace.organization import OrgRole, OrganizationMember
|
||||
from app.core.config import settings
|
||||
from app.core.translation_helper import t # Translation helper
|
||||
from app.core.capabilities import SYSTEM_CAPABILITIES_MATRIX, role_has_capability
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -157,25 +156,6 @@ def check_min_rank(role_key: str):
|
||||
return True
|
||||
return rank_checker
|
||||
|
||||
async def get_current_admin(
|
||||
current_user: User = Depends(get_current_user)
|
||||
) -> User:
|
||||
"""
|
||||
Csak admin/moderátor/superadmin szerepkörrel rendelkező felhasználók számára.
|
||||
"""
|
||||
# A UserRole Enum értékeit használjuk (RBAC Phase 1)
|
||||
allowed_roles = {
|
||||
UserRole.SUPERADMIN,
|
||||
UserRole.ADMIN,
|
||||
UserRole.MODERATOR,
|
||||
}
|
||||
if current_user.role not in allowed_roles:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=t("AUTH.INSUFFICIENT_ADMIN_PERMISSIONS")
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# RBAC Phase 1.5: Staff & Flexible Role Dependencies
|
||||
@@ -210,108 +190,10 @@ async def get_current_staff(
|
||||
return current_user
|
||||
|
||||
|
||||
def RequireRole(allowed_roles: List[UserRole], target_org_id: Optional[int] = None):
|
||||
"""
|
||||
🎯 Flexible role-checking dependency factory with scope-based access.
|
||||
|
||||
Accepts a list of UserRole values. If the current user's role is
|
||||
in the list, access is granted. Otherwise, 403 Forbidden.
|
||||
|
||||
When target_org_id is provided, the RBACService is automatically
|
||||
invoked to check if the staff member has scope-based access to
|
||||
that specific organization.
|
||||
|
||||
Usage:
|
||||
# Simple role check
|
||||
@router.get("/admin/sales")
|
||||
async def sales_dashboard(
|
||||
current_user: User = Depends(get_current_user),
|
||||
_ = Depends(RequireRole([UserRole.SUPERADMIN, UserRole.SALES_REP]))
|
||||
):
|
||||
|
||||
# Scope-based access check
|
||||
@router.get("/admin/organizations/{org_id}")
|
||||
async def get_organization(
|
||||
org_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
_ = Depends(RequireRole(
|
||||
[UserRole.SUPERADMIN, UserRole.ADMIN, UserRole.SALES_REP],
|
||||
target_org_id=org_id # ← triggers scope check
|
||||
))
|
||||
):
|
||||
"""
|
||||
async def role_checker(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> bool:
|
||||
# 1. Check if the user's role is in the allowed list
|
||||
if current_user.role not in allowed_roles:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="AUTH.INSUFFICIENT_PERMISSIONS"
|
||||
)
|
||||
|
||||
# 2. If target_org_id is provided, perform scope-based access check
|
||||
if target_org_id is not None:
|
||||
from app.services.rbac_service import rbac_service
|
||||
try:
|
||||
await rbac_service.check_admin_access(
|
||||
db=db,
|
||||
user=current_user,
|
||||
action="VIEW_DATA", # Generic read access for the dependency
|
||||
target_org_id=target_org_id,
|
||||
)
|
||||
except PermissionError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
return True
|
||||
return role_checker
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# RBAC Phase 2: Capability Dependencies (Kapuőrök)
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def RequireSystemCapability(capability_name: str):
|
||||
"""
|
||||
🎯 Rendszerszintű képesség-ellenőrző dependency.
|
||||
|
||||
Használat:
|
||||
@router.get("/admin/users")
|
||||
async def list_users(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_ = Depends(RequireSystemCapability("can_manage_users"))
|
||||
):
|
||||
|
||||
Logika:
|
||||
1. Ha a user SUPERADMIN → azonnal átengedi (True).
|
||||
2. Egyébként lekéri a user role-ját, és ellenőrzi a
|
||||
SYSTEM_CAPABILITIES_MATRIX-ból, hogy a kért capability True-e.
|
||||
3. Ha nincs meg a capability → 403 Forbidden.
|
||||
"""
|
||||
async def system_capability_checker(
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> bool:
|
||||
# SUPERADMIN mindent visz
|
||||
if current_user.role == UserRole.SUPERADMIN:
|
||||
return True
|
||||
|
||||
# Ellenőrizzük a SYSTEM_CAPABILITIES_MATRIX-ból
|
||||
role_key = current_user.role.value # pl. "ADMIN", "MODERATOR", "USER"
|
||||
if not role_has_capability(role_key, capability_name):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"SYSTEM_CAPABILITY_DENIED: A '{capability_name}' képesség nem elérhető a '{role_key}' szerepkör számára."
|
||||
)
|
||||
return True
|
||||
|
||||
return system_capability_checker
|
||||
|
||||
|
||||
def RequireOrgCapability(capability_name: str):
|
||||
"""
|
||||
🎯 Szervezeti képesség-ellenőrző dependency.
|
||||
@@ -391,4 +273,69 @@ def RequireOrgCapability(capability_name: str):
|
||||
|
||||
return True
|
||||
|
||||
return org_capability_checker
|
||||
return org_capability_checker
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# RBAC Phase 2: DB-Driven Permission Code Dependency (RequirePermission)
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def RequirePermission(permission_code: str):
|
||||
"""
|
||||
🎯 DB-driven permission code dependency factory.
|
||||
|
||||
Queries the system.role_permissions table (via RBACService.get_role_permissions)
|
||||
to check if the current user's role has the specified permission code granted.
|
||||
|
||||
SUPERADMIN bypass: If the user's role rank is 100 (SUPERADMIN), access is
|
||||
granted immediately without a database lookup.
|
||||
|
||||
Usage:
|
||||
@router.get("/admin/sensitive-data")
|
||||
async def get_sensitive_data(
|
||||
current_user: User = Depends(get_current_user),
|
||||
_ = Depends(RequirePermission("fleet:view")),
|
||||
):
|
||||
|
||||
Args:
|
||||
permission_code: The permission code to check (e.g., "fleet:view", "user:create")
|
||||
|
||||
Returns:
|
||||
A dependency callable that returns the current_user if permission is granted,
|
||||
or raises HTTPException(403) if denied.
|
||||
"""
|
||||
async def permission_checker(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
# ── SUPERADMIN bypass ──
|
||||
# SUPERADMIN has rank 100 → bypass all permission checks
|
||||
if current_user.role == UserRole.SUPERADMIN:
|
||||
return current_user
|
||||
|
||||
# ── Resolve role_id ──
|
||||
# Use the DB-driven role_id if available, otherwise fall back to
|
||||
# the legacy UserRole enum for backward compatibility
|
||||
role_id = getattr(current_user, 'role_id', None)
|
||||
if role_id is None:
|
||||
# Fallback: SUPERADMIN already handled above, so this is a non-admin
|
||||
# user without a role_id assigned yet. Deny access.
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Missing permission: {permission_code}. No role_id assigned to user."
|
||||
)
|
||||
|
||||
# ── Query permissions from DB ──
|
||||
from app.services.rbac_service import rbac_service
|
||||
user_permissions = await rbac_service.get_role_permissions(db, role_id)
|
||||
|
||||
# ── Check the specific permission ──
|
||||
if permission_code not in user_permissions:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Missing permission: {permission_code}"
|
||||
)
|
||||
|
||||
return current_user
|
||||
|
||||
return permission_checker
|
||||
|
||||
@@ -33,7 +33,8 @@ router = APIRouter()
|
||||
@router.get("/health-monitor", tags=["Sentinel Monitoring"])
|
||||
async def get_system_health(
|
||||
db: AsyncSession = Depends(deps.get_db),
|
||||
admin: User = Depends(deps.get_current_admin)
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
_ = Depends(deps.RequirePermission("settings:view")),
|
||||
):
|
||||
stats = {}
|
||||
|
||||
@@ -63,7 +64,8 @@ async def get_system_health(
|
||||
@router.get("/pending-actions", response_model=List[Any], tags=["Sentinel Security"])
|
||||
async def list_pending_actions(
|
||||
db: AsyncSession = Depends(deps.get_db),
|
||||
admin: User = Depends(deps.get_current_admin)
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
_ = Depends(deps.RequirePermission("settings:view")),
|
||||
):
|
||||
stmt = select(PendingAction).where(PendingAction.status == ActionStatus.pending)
|
||||
result = await db.execute(stmt)
|
||||
@@ -73,7 +75,8 @@ async def list_pending_actions(
|
||||
async def approve_action(
|
||||
action_id: int,
|
||||
db: AsyncSession = Depends(deps.get_db),
|
||||
admin: User = Depends(deps.get_current_admin)
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
_ = Depends(deps.RequirePermission("settings:edit")),
|
||||
):
|
||||
try:
|
||||
await security_service.approve_action(db, admin.id, action_id)
|
||||
@@ -84,7 +87,8 @@ async def approve_action(
|
||||
@router.get("/parameters", tags=["Dynamic Configuration"])
|
||||
async def list_all_parameters(
|
||||
db: AsyncSession = Depends(deps.get_db),
|
||||
admin: User = Depends(deps.get_current_admin)
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
_ = Depends(deps.RequirePermission("settings:view")),
|
||||
):
|
||||
result = await db.execute(select(SystemParameter))
|
||||
return result.scalars().all()
|
||||
@@ -93,7 +97,8 @@ async def list_all_parameters(
|
||||
async def set_parameter(
|
||||
config: ConfigUpdate,
|
||||
db: AsyncSession = Depends(deps.get_db),
|
||||
admin: User = Depends(deps.get_current_admin)
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
_ = Depends(deps.RequirePermission("settings:edit")),
|
||||
):
|
||||
query = text("""
|
||||
INSERT INTO system.system_parameters (key, value, scope_level, scope_id, category, last_modified_by)
|
||||
@@ -124,7 +129,8 @@ async def get_scoped_parameter(
|
||||
region_id: Optional[str] = None,
|
||||
country_code: Optional[str] = None,
|
||||
db: AsyncSession = Depends(deps.get_db),
|
||||
admin: User = Depends(deps.get_current_admin)
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
_ = Depends(deps.RequirePermission("settings:view")),
|
||||
):
|
||||
"""
|
||||
Hierarchikus paraméterlekérdezés a következő prioritással:
|
||||
@@ -143,7 +149,8 @@ async def get_scoped_parameter(
|
||||
@router.post("/translations/sync", tags=["System Utilities"])
|
||||
async def sync_translations_to_json(
|
||||
db: AsyncSession = Depends(deps.get_db),
|
||||
admin: User = Depends(deps.get_current_admin)
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
_ = Depends(deps.RequirePermission("settings:edit")),
|
||||
):
|
||||
await TranslationService.export_to_json(db)
|
||||
return {"message": "JSON fájlok frissítve."}
|
||||
@@ -151,7 +158,8 @@ async def sync_translations_to_json(
|
||||
|
||||
@router.get("/ping", tags=["Admin Test"])
|
||||
async def admin_ping(
|
||||
current_user: User = Depends(deps.get_current_admin)
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
_ = Depends(deps.RequirePermission("settings:view")),
|
||||
):
|
||||
"""
|
||||
Egyszerű ping végpont admin jogosultság ellenőrzéséhez.
|
||||
@@ -166,8 +174,9 @@ async def admin_ping(
|
||||
async def ban_user(
|
||||
user_id: int,
|
||||
reason: str = Body(..., embed=True),
|
||||
current_admin: User = Depends(deps.get_current_admin),
|
||||
db: AsyncSession = Depends(deps.get_db)
|
||||
db: AsyncSession = Depends(deps.get_db),
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
_ = Depends(deps.RequirePermission("user:manage")),
|
||||
):
|
||||
"""
|
||||
Felhasználó tiltása (Ban Hammer).
|
||||
@@ -205,7 +214,7 @@ async def ban_user(
|
||||
|
||||
# 4. Audit log létrehozása
|
||||
audit_log = SecurityAuditLog(
|
||||
user_id=current_admin.id,
|
||||
user_id=current_user.id,
|
||||
action="ban_user",
|
||||
target_user_id=user_id,
|
||||
details=f"User banned. Reason: {reason}",
|
||||
@@ -226,8 +235,9 @@ async def ban_user(
|
||||
@router.post("/marketplace/services/{staging_id}/approve", tags=["Marketplace Moderation"])
|
||||
async def approve_staged_service(
|
||||
staging_id: int,
|
||||
current_admin: User = Depends(deps.get_current_admin),
|
||||
db: AsyncSession = Depends(deps.get_db)
|
||||
db: AsyncSession = Depends(deps.get_db),
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
_ = Depends(deps.RequirePermission("moderation:manage")),
|
||||
):
|
||||
"""
|
||||
Szerviz jóváhagyása a Piactéren (Kék Pipa).
|
||||
@@ -255,7 +265,7 @@ async def approve_staged_service(
|
||||
|
||||
# Audit log
|
||||
audit_log = SecurityAuditLog(
|
||||
user_id=current_admin.id,
|
||||
user_id=current_user.id,
|
||||
action="approve_service",
|
||||
target_staging_id=staging_id,
|
||||
details=f"Service staging approved: {staging.service_name}",
|
||||
@@ -294,8 +304,9 @@ class PenaltyRequest(BaseModel):
|
||||
async def trigger_ai_pipeline(
|
||||
service_id: int,
|
||||
background_tasks: BackgroundTasks,
|
||||
current_admin: User = Depends(deps.get_current_admin),
|
||||
db: AsyncSession = Depends(deps.get_db)
|
||||
db: AsyncSession = Depends(deps.get_db),
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
_ = Depends(deps.RequirePermission("services:manage")),
|
||||
):
|
||||
"""
|
||||
AI Pipeline manuális indítása egy adott szerviz profilra.
|
||||
@@ -318,7 +329,7 @@ async def trigger_ai_pipeline(
|
||||
|
||||
# Audit log
|
||||
audit_log = SecurityAuditLog(
|
||||
user_id=current_admin.id,
|
||||
user_id=current_user.id,
|
||||
action="trigger_ai_pipeline",
|
||||
target_service_id=service_id,
|
||||
details=f"AI pipeline manually triggered for service {service_id}",
|
||||
@@ -354,8 +365,9 @@ async def run_validation_pipeline(profile_id: int):
|
||||
async def update_service_location(
|
||||
service_id: int,
|
||||
location: LocationUpdate,
|
||||
current_admin: User = Depends(deps.get_current_admin),
|
||||
db: AsyncSession = Depends(deps.get_db)
|
||||
db: AsyncSession = Depends(deps.get_db),
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
_ = Depends(deps.RequirePermission("services:manage")),
|
||||
):
|
||||
"""
|
||||
Szerviz térképes mozgatása (Koordináta frissítés).
|
||||
@@ -379,7 +391,7 @@ async def update_service_location(
|
||||
|
||||
# Audit log
|
||||
audit_log = SecurityAuditLog(
|
||||
user_id=current_admin.id,
|
||||
user_id=current_user.id,
|
||||
action="update_service_location",
|
||||
target_service_id=service_id,
|
||||
details=f"Service location updated to lat={location.latitude}, lon={location.longitude}",
|
||||
@@ -401,8 +413,9 @@ async def update_service_location(
|
||||
async def apply_gamification_penalty(
|
||||
user_id: int,
|
||||
penalty: PenaltyRequest,
|
||||
current_admin: User = Depends(deps.get_current_admin),
|
||||
db: AsyncSession = Depends(deps.get_db)
|
||||
db: AsyncSession = Depends(deps.get_db),
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
_ = Depends(deps.RequirePermission("gamification:manage")),
|
||||
):
|
||||
"""
|
||||
Gamification büntetés kiosztása egy felhasználónak.
|
||||
@@ -446,7 +459,7 @@ async def apply_gamification_penalty(
|
||||
|
||||
# Audit log
|
||||
audit_log = SecurityAuditLog(
|
||||
user_id=current_admin.id,
|
||||
user_id=current_user.id,
|
||||
action="apply_gamification_penalty",
|
||||
target_user_id=user_id,
|
||||
details=f"Gamification penalty applied: level change {penalty.penalty_level}, reason: {penalty.reason}",
|
||||
@@ -485,7 +498,8 @@ async def list_users(
|
||||
is_active: Optional[bool] = Query(None, description="Aktív státusz szűrés"),
|
||||
is_deleted: Optional[bool] = Query(None, description="Törölt státusz szűrés"),
|
||||
db: AsyncSession = Depends(deps.get_db),
|
||||
current_admin: User = Depends(deps.get_current_admin),
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
_ = Depends(deps.RequirePermission("user:view")),
|
||||
):
|
||||
"""
|
||||
Felhasználók listázása lapozással és célzott kereséssel.
|
||||
@@ -646,7 +660,8 @@ async def list_users(
|
||||
async def bulk_user_action(
|
||||
request: BulkActionRequest,
|
||||
db: AsyncSession = Depends(deps.get_db),
|
||||
current_admin: User = Depends(deps.get_current_admin),
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
_ = Depends(deps.RequirePermission("user:manage")),
|
||||
):
|
||||
"""
|
||||
Csoportos műveletek felhasználókon.
|
||||
@@ -658,7 +673,7 @@ async def bulk_user_action(
|
||||
- **restore**: Lágy törlés visszaállítása (is_deleted=False, deleted_at=None). Rang >= 90 (Admin) szükséges.
|
||||
- **hard_delete**: Végleges törlés az adatbázisból. Rang >= 100 (Superadmin) szükséges!
|
||||
"""
|
||||
role_key = current_admin.role.value.upper() if hasattr(current_admin.role, "value") else str(current_admin.role).upper()
|
||||
role_key = current_user.role.value.upper() if hasattr(current_user.role, "value") else str(current_user.role).upper()
|
||||
from app.core.security import DEFAULT_RANK_MAP
|
||||
admin_rank = DEFAULT_RANK_MAP.get(role_key, 0)
|
||||
|
||||
@@ -698,7 +713,7 @@ async def bulk_user_action(
|
||||
affected_count = 0
|
||||
|
||||
for user in users:
|
||||
if user.role == UserRole.SUPERADMIN and user.id != current_admin.id:
|
||||
if user.role == UserRole.SUPERADMIN and user.id != current_user.id:
|
||||
continue
|
||||
|
||||
if request.action == "ban":
|
||||
@@ -717,7 +732,7 @@ async def bulk_user_action(
|
||||
affected_count += 1
|
||||
|
||||
audit_log = SecurityAuditLog(
|
||||
user_id=current_admin.id,
|
||||
user_id=current_user.id,
|
||||
action=f"bulk_{request.action}",
|
||||
details=f"Bulk action '{request.action}' on {affected_count} users. IDs: {request.user_ids}",
|
||||
is_critical=(request.action in ("hard_delete", "ban")),
|
||||
@@ -745,7 +760,8 @@ async def search_organizations(
|
||||
name: str = Query(..., min_length=2, description="Cégnév vagy garázsnév keresése (ILIKE)"),
|
||||
limit: int = Query(20, ge=1, le=100, description="Maximum találatok száma"),
|
||||
db: AsyncSession = Depends(deps.get_db),
|
||||
current_user: User = Depends(deps.get_current_admin),
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
_ = Depends(deps.RequirePermission("org:view")),
|
||||
):
|
||||
"""
|
||||
🔍 Szervezetek / Garázsok keresése név alapján.
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
🏢 Admin Garages (Organizations) CRM API
|
||||
|
||||
Végpontok:
|
||||
GET /admin/organizations — Garázsok listázása előfizetési adatokkal
|
||||
GET /admin/organizations — Garázsok listázása előfizetési adatokkal
|
||||
GET /admin/organizations/{org_id}/details — Garázs részletes adatai (General Tab)
|
||||
PATCH /admin/organizations/{org_id}/subscription — Előfizetés módosítása
|
||||
POST /admin/organizations/{org_id}/members — Új tag hozzáadása a garázshoz
|
||||
PUT /admin/organizations/{org_id}/members/{member_id} — Tag adatainak módosítása
|
||||
DELETE /admin/organizations/{org_id}/members/{member_id} — Tag eltávolítása
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -13,16 +17,23 @@ from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select, func, update as sa_update
|
||||
from sqlalchemy import select, func, update as sa_update, delete as sa_delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload, joinedload, contains_eager
|
||||
from sqlalchemy.orm import selectinload, joinedload
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.api import deps
|
||||
from app.db.session import get_db
|
||||
from app.models.core_logic import SubscriptionTier, OrganizationSubscription
|
||||
from app.models.marketplace.organization import Organization, OrganizationMember
|
||||
from app.models.identity import User
|
||||
from app.models.fleet.organization import ContactPerson
|
||||
from app.models.identity import User, Person
|
||||
from app.schemas.organization import (
|
||||
OrganizationMemberCreate,
|
||||
OrganizationMemberUpdate,
|
||||
OrganizationMemberResponse,
|
||||
PersonBrief,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("admin-organizations")
|
||||
router = APIRouter()
|
||||
@@ -86,6 +97,63 @@ class GarageListResponse(BaseModel):
|
||||
garages: List[GarageListItem]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Pydantic Schemas — Garage Details
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ContactPersonInfo(BaseModel):
|
||||
"""Kapcsolattartó személy adatai."""
|
||||
id: int
|
||||
full_name: str = ""
|
||||
role: str = ""
|
||||
department: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
is_primary: bool = False
|
||||
|
||||
|
||||
class SubscriptionSummary(BaseModel):
|
||||
"""Előfizetés összefoglaló."""
|
||||
tier_name: str = "Free/Fallback"
|
||||
tier_level: int = 0
|
||||
valid_from: Optional[str] = None
|
||||
expires_at: Optional[str] = None
|
||||
is_active: bool = True
|
||||
asset_count: int = 0
|
||||
asset_limit: int = 1
|
||||
|
||||
|
||||
class GarageDetailsResponse(BaseModel):
|
||||
"""Garázs részletes adatai (General Tab)."""
|
||||
# Szervezet adatok
|
||||
id: int
|
||||
name: str
|
||||
full_name: str
|
||||
display_name: Optional[str] = None
|
||||
status: str
|
||||
is_active: bool
|
||||
is_verified: bool
|
||||
org_type: str
|
||||
# Cím adatok
|
||||
address_city: Optional[str] = None
|
||||
address_zip: Optional[str] = None
|
||||
address_street_name: Optional[str] = None
|
||||
address_street_type: Optional[str] = None
|
||||
address_house_number: Optional[str] = None
|
||||
tax_number: Optional[str] = None
|
||||
reg_number: Optional[str] = None
|
||||
# Előfizetés
|
||||
subscription: Optional[SubscriptionSummary] = None
|
||||
# Kapcsolattartó
|
||||
primary_contact: Optional[ContactPersonInfo] = None
|
||||
# Meta
|
||||
created_at: Optional[str] = None
|
||||
member_count: int = 0
|
||||
# P0: Full member list with nested person data
|
||||
members: List[OrganizationMemberResponse] = []
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# GET / — Garázsok listázása
|
||||
# =============================================================================
|
||||
@@ -104,7 +172,8 @@ async def list_organizations(
|
||||
status_filter: Optional[str] = Query(None, description="Szűrés státusz szerint (active/inactive/suspended/pending_verification)"),
|
||||
org_type_filter: Optional[str] = Query(None, description="Szűrés szervezet típus szerint"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin: User = Depends(deps.get_current_admin),
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
_ = Depends(deps.RequirePermission("org:view")),
|
||||
) -> GarageListResponse:
|
||||
"""
|
||||
Garázsok / Szervezetek listázása lapozással és kereséssel.
|
||||
@@ -236,6 +305,200 @@ async def list_organizations(
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# GET /{org_id}/details — Garázs részletes adatai (General Tab)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{org_id}/details",
|
||||
response_model=GarageDetailsResponse,
|
||||
summary="Garázs részletes adatai",
|
||||
description="Visszaadja egy garázs teljes adatait, előfizetési összefoglalóját, "
|
||||
"elsődleges kapcsolattartóját és taglistáját a General Tab számára.",
|
||||
)
|
||||
async def get_organization_details(
|
||||
org_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
_ = Depends(deps.RequirePermission("org:view")),
|
||||
) -> GarageDetailsResponse:
|
||||
"""
|
||||
Garázs részletes adatainak lekérése.
|
||||
|
||||
Visszaadja:
|
||||
- Teljes szervezet adatokat (név, cím, adószám, státusz)
|
||||
- Előfizetés összefoglalót (tier név, lejárat, járműszám/korlát)
|
||||
- Elsődleges kapcsolattartó adatait (fleet.contact_persons)
|
||||
- Tagok teljes listáját (OrganizationMember) nested Person adatokkal
|
||||
"""
|
||||
# 1. Szervezet lekérése subscription_tier kapcsolattal
|
||||
org_stmt = (
|
||||
select(Organization)
|
||||
.options(
|
||||
selectinload(Organization.subscription_tier),
|
||||
)
|
||||
.where(Organization.id == org_id)
|
||||
)
|
||||
org_result = await db.execute(org_stmt)
|
||||
org = org_result.scalar_one_or_none()
|
||||
|
||||
if not org:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Szervezet nem található ID-vel: {org_id}",
|
||||
)
|
||||
|
||||
# 2. Aktív előfizetés lekérése
|
||||
sub_stmt = (
|
||||
select(OrganizationSubscription)
|
||||
.options(selectinload(OrganizationSubscription.tier))
|
||||
.where(
|
||||
OrganizationSubscription.org_id == org_id,
|
||||
OrganizationSubscription.is_active == True,
|
||||
)
|
||||
.order_by(OrganizationSubscription.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
sub_result = await db.execute(sub_stmt)
|
||||
active_sub = sub_result.scalar_one_or_none()
|
||||
|
||||
# 3. Tagok lekérése eager loading-gal (User + Person)
|
||||
members_stmt = (
|
||||
select(OrganizationMember)
|
||||
.options(
|
||||
selectinload(OrganizationMember.user).selectinload(User.person),
|
||||
selectinload(OrganizationMember.person),
|
||||
)
|
||||
.where(
|
||||
OrganizationMember.organization_id == org_id,
|
||||
)
|
||||
.order_by(OrganizationMember.id)
|
||||
)
|
||||
members_result = await db.execute(members_stmt)
|
||||
member_rows = members_result.scalars().all()
|
||||
|
||||
# Taglétszám (aktív tagok száma)
|
||||
member_count = sum(1 for m in member_rows if m.status == "active")
|
||||
|
||||
# 4. Elsődleges kapcsolattartó lekérése
|
||||
contact_stmt = (
|
||||
select(ContactPerson)
|
||||
.options(selectinload(ContactPerson.person))
|
||||
.where(
|
||||
ContactPerson.organization_id == org_id,
|
||||
ContactPerson.is_primary == True,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
contact_result = await db.execute(contact_stmt)
|
||||
contact = contact_result.scalar_one_or_none()
|
||||
|
||||
# 5. Subscription összefoglaló
|
||||
subscription_summary = None
|
||||
if active_sub and active_sub.tier:
|
||||
rules = active_sub.tier.rules or {}
|
||||
asset_limit = (
|
||||
rules.get("max_vehicles")
|
||||
or active_sub.tier.feature_capabilities.get("max_vehicles")
|
||||
or 1
|
||||
)
|
||||
subscription_summary = SubscriptionSummary(
|
||||
tier_name=active_sub.tier.name,
|
||||
tier_level=active_sub.tier.tier_level,
|
||||
valid_from=active_sub.valid_from.isoformat() if active_sub.valid_from else None,
|
||||
expires_at=active_sub.valid_until.isoformat() if active_sub.valid_until else None,
|
||||
is_active=active_sub.is_active,
|
||||
asset_count=0,
|
||||
asset_limit=asset_limit,
|
||||
)
|
||||
elif org.subscription_tier:
|
||||
rules = org.subscription_tier.rules or {}
|
||||
asset_limit = (
|
||||
rules.get("max_vehicles")
|
||||
or org.subscription_tier.feature_capabilities.get("max_vehicles")
|
||||
or 1
|
||||
)
|
||||
subscription_summary = SubscriptionSummary(
|
||||
tier_name=org.subscription_tier.name,
|
||||
tier_level=org.subscription_tier.tier_level,
|
||||
expires_at=org.subscription_expires_at.isoformat() if org.subscription_expires_at else None,
|
||||
asset_limit=asset_limit,
|
||||
)
|
||||
|
||||
# 6. Kapcsolattartó adatok
|
||||
primary_contact = None
|
||||
if contact and contact.person:
|
||||
primary_contact = ContactPersonInfo(
|
||||
id=contact.id,
|
||||
full_name=f"{contact.person.last_name} {contact.person.first_name}",
|
||||
role=contact.role,
|
||||
department=contact.department,
|
||||
phone=contact.person.phone,
|
||||
is_primary=contact.is_primary,
|
||||
)
|
||||
|
||||
# 7. Tagok összeállítása nested Person adatokkal
|
||||
members_list: List[OrganizationMemberResponse] = []
|
||||
for m in member_rows:
|
||||
person_data = None
|
||||
# Try to get Person from the member's direct person relationship first
|
||||
person_obj = m.person
|
||||
# Fallback: get Person through the User relationship
|
||||
if not person_obj and m.user and m.user.person:
|
||||
person_obj = m.user.person
|
||||
|
||||
if person_obj:
|
||||
# Get email from the user record
|
||||
email = m.user.email if m.user else None
|
||||
person_data = PersonBrief(
|
||||
first_name=person_obj.first_name or "",
|
||||
last_name=person_obj.last_name or "",
|
||||
email=email,
|
||||
phone=person_obj.phone,
|
||||
)
|
||||
|
||||
members_list.append(OrganizationMemberResponse(
|
||||
id=m.id,
|
||||
user_id=m.user_id,
|
||||
organization_id=m.organization_id,
|
||||
role=m.role,
|
||||
status=m.status or "active",
|
||||
is_verified=m.is_verified,
|
||||
person=person_data,
|
||||
created_at=m.created_at.isoformat() if m.created_at else None,
|
||||
updated_at=m.updated_at.isoformat() if m.updated_at else None,
|
||||
))
|
||||
|
||||
logger.info(
|
||||
f"Admin {current_user.id} ({current_user.email}) fetched details for org {org_id} "
|
||||
f"({org.full_name}) — {len(members_list)} members"
|
||||
)
|
||||
|
||||
return GarageDetailsResponse(
|
||||
id=org.id,
|
||||
name=org.name,
|
||||
full_name=org.full_name,
|
||||
display_name=org.display_name,
|
||||
status=org.status,
|
||||
is_active=org.is_active,
|
||||
is_verified=org.is_verified,
|
||||
org_type=org.org_type.value if hasattr(org.org_type, "value") else str(org.org_type),
|
||||
address_city=org.address_city,
|
||||
address_zip=org.address_zip,
|
||||
address_street_name=org.address_street_name,
|
||||
address_street_type=org.address_street_type,
|
||||
address_house_number=org.address_house_number,
|
||||
tax_number=org.tax_number,
|
||||
reg_number=org.reg_number,
|
||||
subscription=subscription_summary,
|
||||
primary_contact=primary_contact,
|
||||
created_at=org.created_at.isoformat() if org.created_at else None,
|
||||
member_count=member_count,
|
||||
members=members_list,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PATCH /{org_id}/subscription — Előfizetés módosítása
|
||||
# =============================================================================
|
||||
@@ -251,7 +514,8 @@ async def update_org_subscription(
|
||||
org_id: int,
|
||||
payload: OrgSubscriptionUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin: User = Depends(deps.get_current_admin),
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
_ = Depends(deps.RequirePermission("org:edit")),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Garázs előfizetésének módosítása.
|
||||
@@ -326,7 +590,7 @@ async def update_org_subscription(
|
||||
await db.refresh(new_sub)
|
||||
|
||||
logger.info(
|
||||
f"Admin {admin.id} ({admin.email}) updated subscription for org {org_id}: "
|
||||
f"Admin {current_user.id} ({current_user.email}) updated subscription for org {org_id}: "
|
||||
f"tier_id={payload.tier_id}, tier_name={tier.name}, "
|
||||
f"valid_until={valid_until.isoformat()}"
|
||||
)
|
||||
@@ -344,3 +608,257 @@ async def update_org_subscription(
|
||||
"is_active": new_sub.is_active,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# POST /{org_id}/members — Új tag hozzáadása a garázshoz
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{org_id}/members",
|
||||
response_model=OrganizationMemberResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Új tag hozzáadása",
|
||||
description="Hozzáad egy felhasználót a garázshoz a megadott szerepkörrel.",
|
||||
)
|
||||
async def add_organization_member(
|
||||
org_id: int,
|
||||
payload: OrganizationMemberCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
_ = Depends(deps.RequirePermission("org:manage-members")),
|
||||
) -> OrganizationMemberResponse:
|
||||
"""
|
||||
Új tag hozzáadása egy garázshoz.
|
||||
|
||||
- Ellenőrzi, hogy a szervezet létezik-e.
|
||||
- Ellenőrzi, hogy a user_id létezik-e az identity.users táblában.
|
||||
- Ellenőrzi, hogy a felhasználó még nem tagja a szervezetnek.
|
||||
- Létrehozza az OrganizationMember rekordot.
|
||||
"""
|
||||
# 1. Ellenőrizzük a szervezetet
|
||||
org_stmt = select(Organization).where(Organization.id == org_id)
|
||||
org_result = await db.execute(org_stmt)
|
||||
org = org_result.scalar_one_or_none()
|
||||
|
||||
if not org:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Szervezet nem található ID-vel: {org_id}",
|
||||
)
|
||||
|
||||
# 2. Ellenőrizzük a felhasználót
|
||||
user_stmt = select(User).where(User.id == payload.user_id)
|
||||
user_result = await db.execute(user_stmt)
|
||||
user = user_result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Felhasználó nem található ID-vel: {payload.user_id}",
|
||||
)
|
||||
|
||||
# 3. Ellenőrizzük, hogy a felhasználó még nem tagja a szervezetnek
|
||||
existing_stmt = select(OrganizationMember).where(
|
||||
OrganizationMember.organization_id == org_id,
|
||||
OrganizationMember.user_id == payload.user_id,
|
||||
OrganizationMember.status != "archived",
|
||||
)
|
||||
existing_result = await db.execute(existing_stmt)
|
||||
existing = existing_result.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"A felhasználó (ID: {payload.user_id}) már tagja ennek a szervezetnek.",
|
||||
)
|
||||
|
||||
# 4. Létrehozzuk az új tag rekordot
|
||||
person_id = user.person_id
|
||||
new_member = OrganizationMember(
|
||||
organization_id=org_id,
|
||||
user_id=payload.user_id,
|
||||
person_id=person_id,
|
||||
role=payload.role,
|
||||
status=payload.status,
|
||||
is_verified=False,
|
||||
)
|
||||
db.add(new_member)
|
||||
await db.commit()
|
||||
await db.refresh(new_member)
|
||||
|
||||
# 5. Visszatöltjük a kapcsolódó adatokat a válaszhoz
|
||||
await db.refresh(new_member, attribute_names=["user", "person"])
|
||||
|
||||
# Person adatok összeállítása
|
||||
person_data = None
|
||||
person_obj = new_member.person
|
||||
if not person_obj and new_member.user and new_member.user.person:
|
||||
person_obj = new_member.user.person
|
||||
|
||||
if person_obj:
|
||||
person_data = PersonBrief(
|
||||
first_name=person_obj.first_name or "",
|
||||
last_name=person_obj.last_name or "",
|
||||
email=user.email,
|
||||
phone=person_obj.phone,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Admin {current_user.id} ({current_user.email}) added member user_id={payload.user_id} "
|
||||
f"to org {org_id} ({org.full_name}) with role={payload.role}"
|
||||
)
|
||||
|
||||
return OrganizationMemberResponse(
|
||||
id=new_member.id,
|
||||
user_id=new_member.user_id,
|
||||
organization_id=new_member.organization_id,
|
||||
role=new_member.role,
|
||||
status=new_member.status or "active",
|
||||
is_verified=new_member.is_verified,
|
||||
person=person_data,
|
||||
created_at=new_member.created_at.isoformat() if new_member.created_at else None,
|
||||
updated_at=new_member.updated_at.isoformat() if new_member.updated_at else None,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PUT /{org_id}/members/{member_id} — Tag adatainak módosítása
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{org_id}/members/{member_id}",
|
||||
response_model=OrganizationMemberResponse,
|
||||
summary="Tag adatainak módosítása",
|
||||
description="Frissíti egy meglévő tag szerepkörét és/vagy státuszát.",
|
||||
)
|
||||
async def update_organization_member(
|
||||
org_id: int,
|
||||
member_id: int,
|
||||
payload: OrganizationMemberUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
_ = Depends(deps.RequirePermission("org:manage-members")),
|
||||
) -> OrganizationMemberResponse:
|
||||
"""
|
||||
Meglévő tag adatainak módosítása.
|
||||
|
||||
- Ellenőrzi, hogy a tag rekord létezik és a megadott szervezethez tartozik.
|
||||
- Frissíti a role és/vagy status mezőket.
|
||||
"""
|
||||
# 1. Ellenőrizzük a tag rekordot
|
||||
member_stmt = (
|
||||
select(OrganizationMember)
|
||||
.options(
|
||||
selectinload(OrganizationMember.user).selectinload(User.person),
|
||||
selectinload(OrganizationMember.person),
|
||||
)
|
||||
.where(
|
||||
OrganizationMember.id == member_id,
|
||||
OrganizationMember.organization_id == org_id,
|
||||
)
|
||||
)
|
||||
member_result = await db.execute(member_stmt)
|
||||
member = member_result.scalar_one_or_none()
|
||||
|
||||
if not member:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Tag nem található (ID: {member_id}) a szervezetben (ID: {org_id}).",
|
||||
)
|
||||
|
||||
# 2. Frissítjük a mezőket
|
||||
if payload.role is not None:
|
||||
member.role = payload.role
|
||||
if payload.status is not None:
|
||||
member.status = payload.status
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(member)
|
||||
|
||||
# 3. Person adatok összeállítása
|
||||
person_data = None
|
||||
person_obj = member.person
|
||||
if not person_obj and member.user and member.user.person:
|
||||
person_obj = member.user.person
|
||||
|
||||
if person_obj:
|
||||
email = member.user.email if member.user else None
|
||||
person_data = PersonBrief(
|
||||
first_name=person_obj.first_name or "",
|
||||
last_name=person_obj.last_name or "",
|
||||
email=email,
|
||||
phone=person_obj.phone,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Admin {current_user.id} ({current_user.email}) updated member {member_id} in org {org_id}: "
|
||||
f"role={payload.role}, status={payload.status}"
|
||||
)
|
||||
|
||||
return OrganizationMemberResponse(
|
||||
id=member.id,
|
||||
user_id=member.user_id,
|
||||
organization_id=member.organization_id,
|
||||
role=member.role,
|
||||
status=member.status or "active",
|
||||
is_verified=member.is_verified,
|
||||
person=person_data,
|
||||
created_at=member.created_at.isoformat() if member.created_at else None,
|
||||
updated_at=member.updated_at.isoformat() if member.updated_at else None,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DELETE /{org_id}/members/{member_id} — Tag eltávolítása
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{org_id}/members/{member_id}",
|
||||
status_code=status.HTTP_200_OK,
|
||||
summary="Tag eltávolítása",
|
||||
description="Eltávolít egy tagot a garázsból (soft delete: status → archived).",
|
||||
)
|
||||
async def remove_organization_member(
|
||||
org_id: int,
|
||||
member_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
_ = Depends(deps.RequirePermission("org:manage-members")),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Tag eltávolítása a garázsból.
|
||||
|
||||
Soft delete megközelítés: a rekord nem törlődik, csak a status mező
|
||||
'archived' értékre változik. Ez megőrzi a történeti adatokat.
|
||||
"""
|
||||
# 1. Ellenőrizzük a tag rekordot
|
||||
member_stmt = select(OrganizationMember).where(
|
||||
OrganizationMember.id == member_id,
|
||||
OrganizationMember.organization_id == org_id,
|
||||
)
|
||||
member_result = await db.execute(member_stmt)
|
||||
member = member_result.scalar_one_or_none()
|
||||
|
||||
if not member:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Tag nem található (ID: {member_id}) a szervezetben (ID: {org_id}).",
|
||||
)
|
||||
|
||||
# 2. Soft delete: archived státusz
|
||||
member.status = "archived"
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
f"Admin {current_user.id} ({current_user.email}) removed member {member_id} from org {org_id} "
|
||||
f"(status → archived)"
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Tag (ID: {member_id}) eltávolítva a szervezetből (ID: {org_id}).",
|
||||
}
|
||||
|
||||
@@ -111,7 +111,8 @@ async def list_packages(
|
||||
description="Szűrés záró dátumig (available_until a lifecycle-ben, ISO formátum: YYYY-MM-DD)",
|
||||
),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin: User = Depends(deps.get_current_admin),
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
_ = Depends(deps.RequirePermission("subscription:manage")),
|
||||
) -> SubscriptionTierListResponse:
|
||||
"""
|
||||
Összes előfizetési csomag listázása.
|
||||
@@ -183,7 +184,8 @@ async def list_packages(
|
||||
async def create_package(
|
||||
payload: SubscriptionTierCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin: User = Depends(deps.get_current_admin),
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
_ = Depends(deps.RequirePermission("subscription:manage")),
|
||||
) -> SubscriptionTierResponse:
|
||||
"""
|
||||
Új előfizetési csomag létrehozása.
|
||||
@@ -239,7 +241,7 @@ async def create_package(
|
||||
await db.refresh(tier)
|
||||
|
||||
logger.info(
|
||||
f"Admin {admin.id} ({admin.email}) created subscription tier: "
|
||||
f"Admin {current_user.id} ({current_user.email}) created subscription tier: "
|
||||
f"name={tier.name}, id={tier.id}, "
|
||||
f"is_default_fallback={tier.is_default_fallback}, "
|
||||
f"trial_days_on_signup={tier.trial_days_on_signup}"
|
||||
@@ -263,7 +265,8 @@ async def update_package(
|
||||
tier_id: int,
|
||||
payload: SubscriptionTierUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin: User = Depends(deps.get_current_admin),
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
_ = Depends(deps.RequirePermission("subscription:manage")),
|
||||
) -> SubscriptionTierResponse:
|
||||
"""
|
||||
Meglévő előfizetési csomag részleges frissítése.
|
||||
@@ -363,7 +366,7 @@ async def update_package(
|
||||
await db.refresh(tier)
|
||||
|
||||
logger.info(
|
||||
f"Admin {admin.id} ({admin.email}) updated subscription tier: "
|
||||
f"Admin {current_user.id} ({current_user.email}) updated subscription tier: "
|
||||
f"id={tier.id}, name={tier.name}, "
|
||||
f"is_default_fallback={tier.is_default_fallback}, "
|
||||
f"trial_days_on_signup={tier.trial_days_on_signup}"
|
||||
@@ -386,7 +389,8 @@ async def update_package(
|
||||
async def delete_package(
|
||||
tier_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin: User = Depends(deps.get_current_admin),
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
_ = Depends(deps.RequirePermission("subscription:manage")),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Csomag logikai törlése (soft-delete / kivezetés).
|
||||
@@ -420,7 +424,7 @@ async def delete_package(
|
||||
await db.refresh(tier)
|
||||
|
||||
logger.info(
|
||||
f"Admin {admin.id} ({admin.email}) deactivated subscription tier: "
|
||||
f"Admin {current_user.id} ({current_user.email}) deactivated subscription tier: "
|
||||
f"id={tier.id}, name={tier.name}"
|
||||
)
|
||||
|
||||
|
||||
@@ -7,9 +7,16 @@ Provides admin-facing endpoints for:
|
||||
showing which roles can perform which actions.
|
||||
- PATCH /admin/permissions/override/{org_id} — Updates custom permissions
|
||||
for a specific organization (scope-based override).
|
||||
- GET /admin/permissions/roles — Returns all SystemRole records.
|
||||
- GET /admin/permissions — Returns all SystemPermission records.
|
||||
- PUT /admin/permissions/roles/{role_id}/permissions — Updates the granted
|
||||
permissions for a role (bulk toggle).
|
||||
|
||||
All endpoints are protected by Depends(RequireRole(...)) so only
|
||||
authorized staff (SUPERADMIN, ADMIN) can access them.
|
||||
All endpoints are protected by Depends(RequirePermission(...)) so only
|
||||
authorized staff with the appropriate DB-driven permissions can access them.
|
||||
|
||||
P0 Phase 6: Removed AdminAction enum dependency. All permission lookups
|
||||
are now fully DB-driven via system.role_permissions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -18,20 +25,17 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Body
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy import select, update, delete
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.api import deps
|
||||
from app.api.deps import RequireRole, get_db, get_current_user
|
||||
from app.api.deps import get_db, get_current_user
|
||||
from app.models.identity import User, UserRole
|
||||
from app.models.marketplace.organization import Organization
|
||||
from app.models.system.rbac import SystemRole, SystemPermission, SystemRolePermission
|
||||
from app.services.rbac_service import (
|
||||
rbac_service,
|
||||
AdminAction,
|
||||
ScopeType,
|
||||
ADMIN_SCOPE_ACTIONS,
|
||||
MODERATOR_SCOPE_ACTIONS,
|
||||
SALES_REP_SCOPE_ACTIONS,
|
||||
SERVICE_MGR_SCOPE_ACTIONS,
|
||||
)
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -72,6 +76,53 @@ class PermissionOverrideResponse(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
# ── RBAC Phase 4: DB-Driven Role & Permission Schemas ──
|
||||
|
||||
|
||||
class SystemRoleOut(BaseModel):
|
||||
"""Output schema for a SystemRole."""
|
||||
id: int
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
rank: int
|
||||
is_system: bool
|
||||
is_active: bool
|
||||
permission_ids: List[int] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class SystemPermissionOut(BaseModel):
|
||||
"""Output schema for a SystemPermission."""
|
||||
id: int
|
||||
code: str
|
||||
domain: str
|
||||
action: str
|
||||
description: Optional[str] = None
|
||||
is_system: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class RolePermissionsUpdateRequest(BaseModel):
|
||||
"""Request body for updating a role's granted permissions."""
|
||||
permission_ids: List[int] = Field(
|
||||
...,
|
||||
description="List of permission IDs that should be granted to this role",
|
||||
)
|
||||
|
||||
|
||||
class RolePermissionsUpdateResponse(BaseModel):
|
||||
"""Response after updating role permissions."""
|
||||
status: str
|
||||
role_id: int
|
||||
role_name: str
|
||||
granted_permission_ids: List[int]
|
||||
message: str
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Endpoints
|
||||
# ──────────────────────────────────────────────
|
||||
@@ -84,30 +135,39 @@ class PermissionOverrideResponse(BaseModel):
|
||||
summary="Get the full RBAC permission matrix",
|
||||
)
|
||||
async def get_permission_matrix(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_ = Depends(RequireRole([UserRole.SUPERADMIN, UserRole.ADMIN])),
|
||||
_ = Depends(deps.RequirePermission("permissions:view")),
|
||||
):
|
||||
"""
|
||||
🔐 Returns the complete RBAC permission matrix.
|
||||
|
||||
Shows which roles are permitted to perform which admin actions.
|
||||
Only SUPERADMIN and ADMIN roles can access this endpoint.
|
||||
Protected by RequirePermission("permissions:view").
|
||||
|
||||
P0 Phase 6: Fully DB-driven. All permission codes are fetched
|
||||
from system.permissions and role mappings from system.role_permissions.
|
||||
|
||||
Returns:
|
||||
- matrix: List of {role, actions[]} entries
|
||||
- all_actions: Complete list of all possible admin actions
|
||||
- all_actions: Complete list of all possible permission codes
|
||||
"""
|
||||
# Fetch all permission codes from the DB
|
||||
all_perms_result = await db.execute(
|
||||
select(SystemPermission.code).order_by(SystemPermission.code)
|
||||
)
|
||||
all_actions = [row[0] for row in all_perms_result.all()]
|
||||
|
||||
# Fetch role permissions for each role
|
||||
matrix = []
|
||||
for role in [UserRole.SUPERADMIN, UserRole.ADMIN, UserRole.MODERATOR,
|
||||
UserRole.SALES_REP, UserRole.SERVICE_MGR]:
|
||||
actions = rbac_service.get_permitted_actions(role)
|
||||
actions = await rbac_service.get_permitted_actions(db, role)
|
||||
matrix.append(PermissionMatrixEntry(
|
||||
role=role.value,
|
||||
actions=sorted(actions),
|
||||
))
|
||||
|
||||
all_actions = [a.value for a in AdminAction]
|
||||
|
||||
return PermissionMatrixResponse(
|
||||
matrix=matrix,
|
||||
all_actions=sorted(all_actions),
|
||||
@@ -125,7 +185,7 @@ async def override_org_permission(
|
||||
payload: PermissionOverrideRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_ = Depends(RequireRole([UserRole.SUPERADMIN, UserRole.ADMIN])),
|
||||
_ = Depends(deps.RequirePermission("permissions:edit")),
|
||||
):
|
||||
"""
|
||||
🔐 Updates custom permissions for a specific organization.
|
||||
@@ -138,6 +198,8 @@ async def override_org_permission(
|
||||
the requesting admin has scope-based access to the target
|
||||
organization before applying the override.
|
||||
|
||||
P0 Phase 6: Action validation is now DB-driven via system.permissions.
|
||||
|
||||
Args:
|
||||
org_id: The target organization ID.
|
||||
payload.action: The action to override (e.g., "EDIT_DATA").
|
||||
@@ -146,9 +208,17 @@ async def override_org_permission(
|
||||
Returns:
|
||||
Confirmation of the override operation.
|
||||
"""
|
||||
# 1. Validate that the action exists
|
||||
valid_actions = {a.value for a in AdminAction}
|
||||
if payload.action not in valid_actions:
|
||||
# 1. Validate that the action exists in the DB
|
||||
perm_stmt = select(SystemPermission.code).where(
|
||||
SystemPermission.code == payload.action
|
||||
)
|
||||
perm_result = await db.execute(perm_stmt)
|
||||
if not perm_result.scalar_one_or_none():
|
||||
# Fetch all valid codes for the error message
|
||||
all_codes_result = await db.execute(
|
||||
select(SystemPermission.code).order_by(SystemPermission.code)
|
||||
)
|
||||
valid_actions = [row[0] for row in all_codes_result.all()]
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid action '{payload.action}'. Valid actions: {sorted(valid_actions)}",
|
||||
@@ -159,7 +229,7 @@ async def override_org_permission(
|
||||
await rbac_service.check_admin_access(
|
||||
db=db,
|
||||
user=current_user,
|
||||
action=AdminAction.EDIT_DATA,
|
||||
action="permissions:edit",
|
||||
target_org_id=org_id,
|
||||
)
|
||||
except PermissionError as e:
|
||||
@@ -180,17 +250,12 @@ async def override_org_permission(
|
||||
)
|
||||
|
||||
# 4. Update custom permissions via the settings JSONB field
|
||||
# The Organization model has a `settings` JSONB column that stores
|
||||
# dynamic org-level configuration. We store permission overrides
|
||||
# under a "permission_overrides" key within settings.
|
||||
if not isinstance(org.settings, dict):
|
||||
org.settings = {}
|
||||
|
||||
# Ensure the permission_overrides sub-key exists
|
||||
if "permission_overrides" not in org.settings:
|
||||
org.settings["permission_overrides"] = {}
|
||||
|
||||
# Apply the override
|
||||
org.settings["permission_overrides"][payload.action] = payload.granted
|
||||
|
||||
# 5. Persist
|
||||
@@ -210,3 +275,222 @@ async def override_org_permission(
|
||||
granted=payload.granted,
|
||||
message=f"Permission '{payload.action}' {'granted' if payload.granted else 'revoked'} for organization {org_id}.",
|
||||
)
|
||||
|
||||
|
||||
# ── RBAC Phase 4: DB-Driven Role & Permission Endpoints ──
|
||||
|
||||
|
||||
@router.get(
|
||||
"/admin/permissions/roles",
|
||||
response_model=List[SystemRoleOut],
|
||||
tags=["Admin Permissions"],
|
||||
summary="Get all system roles with their granted permission IDs",
|
||||
)
|
||||
async def get_all_roles(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_ = Depends(deps.RequirePermission("permissions:view")),
|
||||
):
|
||||
"""
|
||||
🔐 Returns all SystemRole records with their associated permission IDs.
|
||||
|
||||
Each role includes:
|
||||
- id, name, description, rank, is_system, is_active
|
||||
- permission_ids: List of permission IDs granted to this role
|
||||
|
||||
Protected by RequirePermission("permissions:view").
|
||||
"""
|
||||
stmt = (
|
||||
select(SystemRole)
|
||||
.options(selectinload(SystemRole.permissions))
|
||||
.order_by(SystemRole.rank.desc())
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
roles = result.unique().scalars().all()
|
||||
|
||||
role_list = []
|
||||
for role in roles:
|
||||
permission_ids = [
|
||||
rp.permission_id for rp in role.permissions if rp.granted
|
||||
]
|
||||
role_list.append(SystemRoleOut(
|
||||
id=role.id,
|
||||
name=role.name,
|
||||
description=role.description,
|
||||
rank=role.rank,
|
||||
is_system=role.is_system,
|
||||
is_active=role.is_active,
|
||||
permission_ids=permission_ids,
|
||||
))
|
||||
|
||||
return role_list
|
||||
|
||||
|
||||
@router.get(
|
||||
"/admin/permissions",
|
||||
response_model=List[SystemPermissionOut],
|
||||
tags=["Admin Permissions"],
|
||||
summary="Get all system permissions",
|
||||
)
|
||||
async def get_all_permissions(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_ = Depends(deps.RequirePermission("permissions:view")),
|
||||
):
|
||||
"""
|
||||
🔐 Returns all SystemPermission records.
|
||||
|
||||
Each permission includes:
|
||||
- id, code, domain, action, description, is_system
|
||||
|
||||
Protected by RequirePermission("permissions:view").
|
||||
"""
|
||||
stmt = select(SystemPermission).order_by(SystemPermission.domain, SystemPermission.action)
|
||||
result = await db.execute(stmt)
|
||||
permissions = result.scalars().all()
|
||||
|
||||
return [
|
||||
SystemPermissionOut(
|
||||
id=p.id,
|
||||
code=p.code,
|
||||
domain=p.domain,
|
||||
action=p.action,
|
||||
description=p.description,
|
||||
is_system=p.is_system,
|
||||
)
|
||||
for p in permissions
|
||||
]
|
||||
|
||||
|
||||
@router.put(
|
||||
"/admin/permissions/roles/{role_id}/permissions",
|
||||
response_model=RolePermissionsUpdateResponse,
|
||||
tags=["Admin Permissions"],
|
||||
summary="Update granted permissions for a role",
|
||||
)
|
||||
async def update_role_permissions(
|
||||
role_id: int,
|
||||
payload: RolePermissionsUpdateRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_ = Depends(deps.RequirePermission("permissions:edit")),
|
||||
):
|
||||
"""
|
||||
🔐 Updates the granted permissions for a specific role.
|
||||
|
||||
This endpoint performs a full replacement of the role's granted
|
||||
permissions. The provided permission_ids list becomes the new set
|
||||
of granted permissions. Any existing mappings not in the list
|
||||
are removed.
|
||||
|
||||
SUPERADMIN roles (is_system=True, rank=100) are protected:
|
||||
- Their permissions cannot be modified through this endpoint
|
||||
to prevent accidental lockout.
|
||||
|
||||
Args:
|
||||
role_id: The target role ID.
|
||||
payload.permission_ids: The complete list of permission IDs to grant.
|
||||
|
||||
Returns:
|
||||
Confirmation with the updated permission IDs.
|
||||
"""
|
||||
# 1. Fetch the role
|
||||
stmt = select(SystemRole).where(SystemRole.id == role_id)
|
||||
result = await db.execute(stmt)
|
||||
role = result.scalar_one_or_none()
|
||||
|
||||
if not role:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Role with id={role_id} not found.",
|
||||
)
|
||||
|
||||
# 2. Protect SUPERADMIN system roles
|
||||
if role.is_system and role.rank >= 100:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Cannot modify permissions for the SUPERADMIN system role. "
|
||||
"This would risk locking administrators out of the system.",
|
||||
)
|
||||
|
||||
# 3. Validate that all permission_ids exist
|
||||
if payload.permission_ids:
|
||||
perm_stmt = select(SystemPermission.id).where(
|
||||
SystemPermission.id.in_(payload.permission_ids)
|
||||
)
|
||||
perm_result = await db.execute(perm_stmt)
|
||||
existing_ids = {row[0] for row in perm_result.all()}
|
||||
|
||||
missing_ids = set(payload.permission_ids) - existing_ids
|
||||
if missing_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid permission IDs: {sorted(missing_ids)}. These permissions do not exist.",
|
||||
)
|
||||
|
||||
# 4. Remove existing role-permission mappings
|
||||
delete_stmt = delete(SystemRolePermission).where(
|
||||
SystemRolePermission.role_id == role_id
|
||||
)
|
||||
await db.execute(delete_stmt)
|
||||
|
||||
# 5. Insert new mappings
|
||||
for perm_id in payload.permission_ids:
|
||||
db.add(SystemRolePermission(
|
||||
role_id=role_id,
|
||||
permission_id=perm_id,
|
||||
granted=True,
|
||||
))
|
||||
|
||||
await db.commit()
|
||||
|
||||
# 6. Invalidate cache
|
||||
rbac_service.invalidate_role_cache(role_id)
|
||||
|
||||
logger.info(
|
||||
f"Role permissions updated: role_id={role_id}, "
|
||||
f"permissions={payload.permission_ids}, "
|
||||
f"by_user={current_user.id}"
|
||||
)
|
||||
|
||||
return RolePermissionsUpdateResponse(
|
||||
status="success",
|
||||
role_id=role_id,
|
||||
role_name=role.name,
|
||||
granted_permission_ids=payload.permission_ids,
|
||||
message=f"Permissions updated for role '{role.name}' ({len(payload.permission_ids)} granted).",
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# RBAC Phase 2: Test Endpoint (Verification)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get(
|
||||
"/admin/rbac-test",
|
||||
tags=["Admin Permissions (RBAC)"],
|
||||
summary="RBAC Phase 2 verification endpoint",
|
||||
)
|
||||
async def rbac_test_endpoint(
|
||||
current_user: User = Depends(get_current_user),
|
||||
_ = Depends(deps.RequirePermission("fleet:view")),
|
||||
):
|
||||
"""
|
||||
🔐 RBAC Phase 2 verification endpoint.
|
||||
|
||||
Protected by Depends(RequirePermission("fleet:view")).
|
||||
- SUPERADMIN: Always passes (bypass).
|
||||
- Users with fleet:view permission: Passes.
|
||||
- Users without fleet:view permission: 403 Forbidden.
|
||||
|
||||
Returns the current user info if permission is granted.
|
||||
"""
|
||||
return {
|
||||
"status": "ok",
|
||||
"message": "RBAC Phase 2 permission check passed.",
|
||||
"user_id": current_user.id,
|
||||
"user_role": current_user.role.value,
|
||||
"role_id": current_user.role_id,
|
||||
"permission_checked": "fleet:view",
|
||||
}
|
||||
|
||||
@@ -27,7 +27,8 @@ async def list_service_catalog(
|
||||
limit: int = Query(50, ge=1, le=200, description="Maximum visszaadott rekordok száma"),
|
||||
is_active: bool | None = Query(None, description="Szűrés aktív/inaktív státuszra"),
|
||||
db: AsyncSession = Depends(deps.get_db),
|
||||
admin: User = Depends(deps.get_current_admin),
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
_ = Depends(deps.RequirePermission("services:manage")),
|
||||
):
|
||||
"""
|
||||
Szolgáltatás katalógus bejegyzések listázása lapozással.
|
||||
@@ -53,7 +54,8 @@ async def list_service_catalog(
|
||||
async def create_service_catalog(
|
||||
data: ServiceCatalogCreate,
|
||||
db: AsyncSession = Depends(deps.get_db),
|
||||
admin: User = Depends(deps.get_current_admin),
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
_ = Depends(deps.RequirePermission("services:manage")),
|
||||
):
|
||||
"""
|
||||
Új szolgáltatás katalógus bejegyzés létrehozása.
|
||||
@@ -86,7 +88,8 @@ async def update_service_catalog(
|
||||
service_id: int,
|
||||
data: ServiceCatalogUpdate,
|
||||
db: AsyncSession = Depends(deps.get_db),
|
||||
admin: User = Depends(deps.get_current_admin),
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
_ = Depends(deps.RequirePermission("services:manage")),
|
||||
):
|
||||
"""
|
||||
Meglévő szolgáltatás katalógus bejegyzés módosítása.
|
||||
|
||||
@@ -306,7 +306,7 @@ async def get_current_user_profile(
|
||||
"""
|
||||
from app.schemas.user import UserResponse
|
||||
from app.api.v1.endpoints.users import _build_user_response
|
||||
response_data = _build_user_response(current_user)
|
||||
response_data = await _build_user_response(current_user, db=db)
|
||||
return UserResponse.model_validate(response_data)
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/finance_admin.py
|
||||
"""
|
||||
Finance Admin API endpoints for managing Issuers with strict RBAC protection.
|
||||
Only users with rank >= 90 (Superadmin/Finance Admin) can access these endpoints.
|
||||
Protected by DB-driven RequirePermission("finance:view") dependency.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
@@ -10,36 +10,22 @@ from sqlalchemy import select
|
||||
from typing import List
|
||||
|
||||
from app.api import deps
|
||||
from app.models.identity import User, UserRole
|
||||
from app.models.identity import User
|
||||
from app.models.marketplace.finance import Issuer
|
||||
from app.schemas.finance import IssuerResponse, IssuerUpdate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def check_finance_admin_access(
|
||||
current_user: User = Depends(deps.get_current_active_user)
|
||||
):
|
||||
"""
|
||||
RBAC protection: only users with rank >= 90 (Superadmin/Finance Admin) can access.
|
||||
In our system, this translates to role being 'superadmin' or 'admin'.
|
||||
"""
|
||||
if current_user.role not in [UserRole.SUPERADMIN, UserRole.ADMIN]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions. Rank >= 90 (Superadmin/Finance Admin) required."
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
@router.get("/", response_model=List[IssuerResponse], tags=["finance-admin"])
|
||||
async def list_issuers(
|
||||
db: AsyncSession = Depends(deps.get_db),
|
||||
admin: User = Depends(check_finance_admin_access)
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
_ = Depends(deps.RequirePermission("finance:view")),
|
||||
):
|
||||
"""
|
||||
List all Issuers (billing entities).
|
||||
Only accessible by Superadmin/Finance Admin (rank >= 90).
|
||||
Protected by RequirePermission("finance:view").
|
||||
"""
|
||||
result = await db.execute(select(Issuer).order_by(Issuer.id))
|
||||
issuers = result.scalars().all()
|
||||
@@ -51,11 +37,12 @@ async def update_issuer(
|
||||
issuer_id: int,
|
||||
issuer_update: IssuerUpdate,
|
||||
db: AsyncSession = Depends(deps.get_db),
|
||||
admin: User = Depends(check_finance_admin_access)
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
_ = Depends(deps.RequirePermission("finance:edit")),
|
||||
):
|
||||
"""
|
||||
Update an Issuer's details (activate/deactivate, revenue limit, API config).
|
||||
Only accessible by Superadmin/Finance Admin (rank >= 90).
|
||||
Protected by RequirePermission("finance:edit").
|
||||
"""
|
||||
result = await db.execute(select(Issuer).where(Issuer.id == issuer_id))
|
||||
issuer = result.scalar_one_or_none()
|
||||
|
||||
@@ -14,11 +14,10 @@ from sqlalchemy.orm import selectinload
|
||||
from pydantic import BaseModel, Field, ConfigDict, EmailStr
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.api.deps import get_current_user, RequireSystemCapability
|
||||
from app.api.deps import get_current_user
|
||||
from app.schemas.organization import CorpOnboardIn, CorpOnboardResponse, OrganizationUpdate, OrganizationResponse
|
||||
from app.schemas.subscription import SubscriptionTierResponse
|
||||
from app.services.billing_engine import upgrade_org_subscription
|
||||
from app.core.capabilities import Capability
|
||||
from app.models.marketplace.organization import Organization, OrgType, OrganizationMember, Branch, OrgUserRole, OrgRole
|
||||
from app.models.identity import User, OneTimePassword
|
||||
from app.core.config import settings
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api import deps
|
||||
from app.api.deps import get_db, get_current_user
|
||||
from app.models.identity import User, UserRole
|
||||
from app.services.security_service import security_service
|
||||
@@ -23,7 +24,8 @@ router = APIRouter()
|
||||
async def request_action(
|
||||
request: PendingActionCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
current_user: User = Depends(get_current_user),
|
||||
_ = Depends(deps.RequirePermission("dual-control:request")),
|
||||
):
|
||||
"""
|
||||
Dual Control: Jóváhagyási kérelem indítása kiemelt művelethez.
|
||||
@@ -35,12 +37,6 @@ async def request_action(
|
||||
- SOFT_DELETE_USER: Felhasználó soft delete
|
||||
- ORGANIZATION_TRANSFER: Szervezet tulajdonjog átadása
|
||||
"""
|
||||
# Csak admin és superadmin kezdeményezhet kiemelt műveleteket
|
||||
if current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Csak adminisztrátorok kezdeményezhetnek Dual Control műveleteket."
|
||||
)
|
||||
|
||||
try:
|
||||
action = await security_service.request_action(
|
||||
@@ -82,18 +78,14 @@ async def approve_action(
|
||||
action_id: int,
|
||||
approve_data: PendingActionApprove,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
current_user: User = Depends(get_current_user),
|
||||
_ = Depends(deps.RequirePermission("dual-control:approve")),
|
||||
):
|
||||
"""
|
||||
Dual Control: Művelet jóváhagyása.
|
||||
|
||||
Csak admin/superadmin hagyhat jóvá, és nem lehet a saját kérése.
|
||||
"""
|
||||
if current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Csak adminisztrátorok hagyhatnak jóvá műveleteket."
|
||||
)
|
||||
|
||||
try:
|
||||
await security_service.approve_action(db, approver_id=current_user.id, action_id=action_id)
|
||||
@@ -115,18 +107,14 @@ async def reject_action(
|
||||
action_id: int,
|
||||
reject_data: PendingActionReject,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
current_user: User = Depends(get_current_user),
|
||||
_ = Depends(deps.RequirePermission("dual-control:approve")),
|
||||
):
|
||||
"""
|
||||
Dual Control: Művelet elutasítása.
|
||||
|
||||
Csak admin/superadmin utasíthat el, és nem lehet a saját kérése.
|
||||
"""
|
||||
if current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Csak adminisztrátorok utasíthatnak el műveleteket."
|
||||
)
|
||||
|
||||
try:
|
||||
await security_service.reject_action(
|
||||
@@ -150,7 +138,8 @@ async def reject_action(
|
||||
async def get_action(
|
||||
action_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
current_user: User = Depends(get_current_user),
|
||||
_ = Depends(deps.RequirePermission("dual-control:view")),
|
||||
):
|
||||
"""
|
||||
Egy konkrét Dual Control művelet lekérdezése.
|
||||
@@ -164,7 +153,7 @@ async def get_action(
|
||||
if not action:
|
||||
raise HTTPException(status_code=404, detail="Művelet nem található.")
|
||||
|
||||
if current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN] and action.requester_id != current_user.id:
|
||||
if current_user.id != action.requester_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Nincs jogosultságod ehhez a művelethez."
|
||||
|
||||
@@ -5,6 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, update, and_
|
||||
from typing import List, Optional, Dict, Any
|
||||
|
||||
from app.api import deps
|
||||
from app.api.deps import get_db, get_current_user
|
||||
from app.schemas.system import (
|
||||
SystemParameterResponse,
|
||||
@@ -114,20 +115,14 @@ async def update_system_parameter(
|
||||
param_in: SystemParameterUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user=Depends(get_current_user),
|
||||
_ = Depends(deps.RequirePermission("system:manage")),
|
||||
scope_level: ParameterScope = Query("global", description="Scope szint (alapértelmezett: global)"),
|
||||
scope_id: Optional[str] = Query(None, description="Scope azonosító"),
|
||||
):
|
||||
"""
|
||||
Módosítja egy létező paraméter value (JSONB) vagy is_active mezőjét (Admin funkció).
|
||||
Csak superadmin vagy admin jogosultságú felhasználók használhatják.
|
||||
Protected by RequirePermission("system:manage").
|
||||
"""
|
||||
# Jogosultság ellenőrzése
|
||||
if current_user.role not in (UserRole.SUPERADMIN, UserRole.ADMIN):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Insufficient permissions. Only superadmin or admin can update system parameters."
|
||||
)
|
||||
|
||||
# Paraméter keresése
|
||||
query = select(SystemParameter).where(
|
||||
SystemParameter.key == key,
|
||||
|
||||
@@ -41,16 +41,17 @@ class NetworkResponse(BaseModel):
|
||||
level3: List[NetworkMemberL2L3]
|
||||
|
||||
|
||||
def _build_user_response(user: User, active_org_id: Optional[int] = None, db: Optional[AsyncSession] = None) -> dict:
|
||||
async def _build_user_response(user: User, active_org_id: Optional[int] = None, db: Optional[AsyncSession] = None) -> dict:
|
||||
"""
|
||||
Segédfüggvény a UserResponse dict előállításához.
|
||||
Beágyazza a Person és Address adatokat a 'person' mezőbe.
|
||||
RBAC Phase 3: system_capabilities és org_capabilities is bekerül a válaszba.
|
||||
P0: max_vehicles és max_garages a subscription_tier JSONB rules-ból.
|
||||
P0 Phase 6: system_capabilities now populated via DB-driven RBAC lookup.
|
||||
"""
|
||||
from app.models.marketplace.organization import Organization, OrganizationMember
|
||||
from app.models.marketplace.organization import OrgUserRole
|
||||
from app.core.capabilities import get_capabilities_for_role
|
||||
from app.services.rbac_service import rbac_service
|
||||
|
||||
# Determine active organization ID
|
||||
if active_org_id is None:
|
||||
@@ -102,9 +103,22 @@ def _build_user_response(user: User, active_org_id: Optional[int] = None, db: Op
|
||||
first_name = person.first_name if person else ""
|
||||
last_name = person.last_name if person else ""
|
||||
|
||||
# ── RBAC Phase 3: Resolve system capabilities ──
|
||||
# ── RBAC Phase 3/6: Resolve system capabilities via DB-driven RBAC ──
|
||||
role_key = user.role.value if hasattr(user.role, 'value') else str(user.role)
|
||||
system_capabilities = get_capabilities_for_role(role_key)
|
||||
system_capabilities: Dict[str, bool] = {}
|
||||
|
||||
if db is not None:
|
||||
# Use the role_id from the user's SystemRole mapping
|
||||
role_id = getattr(user, 'role_id', None)
|
||||
if role_id is not None:
|
||||
try:
|
||||
# Fetch all granted permission codes for this role from the DB
|
||||
perm_set = await rbac_service.get_role_permissions(db, role_id)
|
||||
# Convert the set of codes into a dict mapping code -> True
|
||||
system_capabilities = {code: True for code in perm_set}
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to resolve system_capabilities for user {user.id}: {e}")
|
||||
system_capabilities = {}
|
||||
|
||||
# ── RBAC Phase 3: Resolve org capabilities ──
|
||||
org_capabilities: Dict[str, Dict[str, bool]] = {}
|
||||
@@ -135,7 +149,7 @@ def _build_user_response(user: User, active_org_id: Optional[int] = None, db: Op
|
||||
"preferred_language": user.preferred_language or "hu",
|
||||
"person": person_data,
|
||||
"is_last_admin": False, # Default, will be overridden in read_users_me
|
||||
# RBAC Phase 3: Rendszerszintű képességek
|
||||
# RBAC Phase 3/6: Rendszerszintű képességek (DB-driven)
|
||||
"system_capabilities": system_capabilities,
|
||||
# RBAC Phase 3: Szervezeti képességek (feltöltve a read_users_me végpontban)
|
||||
"org_capabilities": {},
|
||||
@@ -150,8 +164,7 @@ async def read_users_me(
|
||||
"""Visszaadja a bejelentkezett felhasználó profilját beágyazott Person és Address adatokkal.
|
||||
RBAC Phase 3: system_capabilities és org_capabilities is bekerül a válaszba."""
|
||||
from app.models.marketplace.organization import Organization, OrganizationMember
|
||||
from app.models.marketplace.organization import OrgUserRole
|
||||
from app.core.capabilities import get_capabilities_for_role
|
||||
from app.models.marketplace.organization import OrgUserRole, OrgRole
|
||||
|
||||
# Determine active organization ID
|
||||
active_org_id = None
|
||||
@@ -252,7 +265,8 @@ async def read_users_me(
|
||||
max_garages = int(allowances.get("max_garages", 1))
|
||||
|
||||
# ── RBAC Phase 3: Build base response ──
|
||||
response_data = _build_user_response(current_user, active_org_id)
|
||||
# P0 Phase 6: Pass db so system_capabilities gets populated from DB
|
||||
response_data = await _build_user_response(current_user, active_org_id, db)
|
||||
response_data["is_last_admin"] = is_last_admin
|
||||
response_data["max_vehicles"] = max_vehicles
|
||||
response_data["max_garages"] = max_garages
|
||||
@@ -383,7 +397,7 @@ async def update_my_person(
|
||||
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
|
||||
|
||||
# Build and return the full response
|
||||
response_data = _build_user_response(user)
|
||||
response_data = await _build_user_response(user)
|
||||
return UserResponse.model_validate(response_data)
|
||||
|
||||
|
||||
@@ -487,7 +501,7 @@ async def update_user_preferences(
|
||||
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
|
||||
|
||||
# Return the Pydantic model instead of raw SQLAlchemy object
|
||||
response_data = _build_user_response(current_user)
|
||||
response_data = await _build_user_response(current_user)
|
||||
return UserResponse.model_validate(response_data)
|
||||
|
||||
|
||||
@@ -549,7 +563,7 @@ async def update_active_organization(
|
||||
access_token, _ = create_tokens(data=token_payload)
|
||||
|
||||
# Return user data with new token
|
||||
response_data = _build_user_response(current_user)
|
||||
response_data = await _build_user_response(current_user)
|
||||
return UserWithTokenResponse(
|
||||
user=UserResponse.model_validate(response_data),
|
||||
access_token=access_token,
|
||||
|
||||
@@ -1,295 +1,11 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/core/capabilities.py
|
||||
"""
|
||||
🎯 SYSTEM CAPABILITIES MATRIX (RBAC Phase 1)
|
||||
🎯 SYSTEM CAPABILITIES REGISTRY (Deprecated - Phase 6 Cleanup)
|
||||
|
||||
Fine-grained, capability-driven permission matrix for system-level roles.
|
||||
Each role maps to a set of boolean capabilities that define what the role
|
||||
can do within the platform.
|
||||
The Capability class with hardcoded string constants has been removed.
|
||||
The DB-driven RBAC system (system.role_permissions table) is now the
|
||||
single source of truth for permission checks.
|
||||
|
||||
Architecture:
|
||||
- SUPERADMIN: Full system access (rank 100)
|
||||
- ADMIN: Full admin panel access (rank 90)
|
||||
- MODERATOR: Content moderation capabilities (rank 75)
|
||||
- SALES_REP: Sales & discount management (rank 30)
|
||||
- SERVICE_MGR: Service provider & taxonomy management (rank 40)
|
||||
- USER: Basic platform user (rank 10)
|
||||
All permission codes are stored in the system.permissions table and
|
||||
mapped to roles via system.role_permissions.
|
||||
"""
|
||||
|
||||
from typing import Dict, Set
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# CAPABILITY KEYS (for type-safe references)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
class Capability:
|
||||
"""Central registry of all capability keys."""
|
||||
|
||||
# ── Admin & System ──
|
||||
CAN_MANAGE_USERS = "can_manage_users"
|
||||
CAN_MANAGE_ROLES = "can_manage_roles"
|
||||
CAN_VIEW_SYSTEM_LOGS = "can_view_system_logs"
|
||||
CAN_MANAGE_SYSTEM_CONFIG = "can_manage_system_config"
|
||||
CAN_MANAGE_TRANSLATIONS = "can_manage_translations"
|
||||
|
||||
# ── Moderation ──
|
||||
CAN_MODERATE_CONTENT = "can_moderate_content"
|
||||
CAN_VIEW_REPORTS = "can_view_reports"
|
||||
CAN_SUSPEND_USERS = "can_suspend_users"
|
||||
CAN_APPROVE_PROVIDER = "can_approve_provider"
|
||||
|
||||
# ── Sales & Marketing ──
|
||||
CAN_VIEW_ORG_STATS = "can_view_org_stats"
|
||||
CAN_MANAGE_DISCOUNTS = "can_manage_discounts"
|
||||
CAN_MANAGE_PROMOTIONS = "can_manage_promotions"
|
||||
CAN_VIEW_SALES_PIPELINE = "can_view_sales_pipeline"
|
||||
|
||||
# ── Service Management ──
|
||||
CAN_EDIT_MASTER_TAXONOMY = "can_edit_master_taxonomy"
|
||||
CAN_MANAGE_SERVICE_CATEGORIES = "can_manage_service_categories"
|
||||
CAN_VERIFY_SERVICE_PROVIDERS = "can_verify_service_providers"
|
||||
CAN_MANAGE_EXPERTISE_TAGS = "can_manage_expertise_tags"
|
||||
|
||||
# ── Vehicle & Catalog ──
|
||||
CAN_MANAGE_VEHICLE_CATALOG = "can_manage_vehicle_catalog"
|
||||
CAN_APPROVE_VEHICLE_DEFINITIONS = "can_approve_vehicle_definitions"
|
||||
CAN_VIEW_TECHNICAL_SPECS = "can_view_technical_specs"
|
||||
|
||||
# ── Finance ──
|
||||
CAN_VIEW_FINANCIAL_REPORTS = "can_view_financial_reports"
|
||||
CAN_MANAGE_BILLING = "can_manage_billing"
|
||||
CAN_ISSUE_REFUNDS = "can_issue_refunds"
|
||||
CAN_MANAGE_SUBSCRIPTIONS = "can_manage_subscriptions"
|
||||
|
||||
# ── Basic User ──
|
||||
CAN_MANAGE_OWN_PROFILE = "can_manage_own_profile"
|
||||
CAN_MANAGE_OWN_VEHICLES = "can_manage_own_vehicles"
|
||||
CAN_CREATE_SERVICE_REQUEST = "can_create_service_request"
|
||||
CAN_VIEW_PUBLIC_CATALOG = "can_view_public_catalog"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# SYSTEM CAPABILITIES MATRIX
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
SYSTEM_CAPABILITIES_MATRIX: Dict[str, Dict[str, bool]] = {
|
||||
# ── SUPERADMIN: Full system access ──
|
||||
"SUPERADMIN": {
|
||||
# Admin & System
|
||||
Capability.CAN_MANAGE_USERS: True,
|
||||
Capability.CAN_MANAGE_ROLES: True,
|
||||
Capability.CAN_VIEW_SYSTEM_LOGS: True,
|
||||
Capability.CAN_MANAGE_SYSTEM_CONFIG: True,
|
||||
Capability.CAN_MANAGE_TRANSLATIONS: True,
|
||||
# Moderation
|
||||
Capability.CAN_MODERATE_CONTENT: True,
|
||||
Capability.CAN_VIEW_REPORTS: True,
|
||||
Capability.CAN_SUSPEND_USERS: True,
|
||||
Capability.CAN_APPROVE_PROVIDER: True,
|
||||
# Sales
|
||||
Capability.CAN_VIEW_ORG_STATS: True,
|
||||
Capability.CAN_MANAGE_DISCOUNTS: True,
|
||||
Capability.CAN_MANAGE_PROMOTIONS: True,
|
||||
Capability.CAN_VIEW_SALES_PIPELINE: True,
|
||||
# Service Management
|
||||
Capability.CAN_EDIT_MASTER_TAXONOMY: True,
|
||||
Capability.CAN_MANAGE_SERVICE_CATEGORIES: True,
|
||||
Capability.CAN_VERIFY_SERVICE_PROVIDERS: True,
|
||||
Capability.CAN_MANAGE_EXPERTISE_TAGS: True,
|
||||
# Vehicle & Catalog
|
||||
Capability.CAN_MANAGE_VEHICLE_CATALOG: True,
|
||||
Capability.CAN_APPROVE_VEHICLE_DEFINITIONS: True,
|
||||
Capability.CAN_VIEW_TECHNICAL_SPECS: True,
|
||||
# Finance
|
||||
Capability.CAN_VIEW_FINANCIAL_REPORTS: True,
|
||||
Capability.CAN_MANAGE_BILLING: True,
|
||||
Capability.CAN_ISSUE_REFUNDS: True,
|
||||
Capability.CAN_MANAGE_SUBSCRIPTIONS: True,
|
||||
# Basic User
|
||||
Capability.CAN_MANAGE_OWN_PROFILE: True,
|
||||
Capability.CAN_MANAGE_OWN_VEHICLES: True,
|
||||
Capability.CAN_CREATE_SERVICE_REQUEST: True,
|
||||
Capability.CAN_VIEW_PUBLIC_CATALOG: True,
|
||||
},
|
||||
|
||||
# ── ADMIN: Full admin panel access ──
|
||||
"ADMIN": {
|
||||
Capability.CAN_MANAGE_USERS: True,
|
||||
Capability.CAN_MANAGE_ROLES: False,
|
||||
Capability.CAN_VIEW_SYSTEM_LOGS: True,
|
||||
Capability.CAN_MANAGE_SYSTEM_CONFIG: False,
|
||||
Capability.CAN_MANAGE_TRANSLATIONS: True,
|
||||
Capability.CAN_MODERATE_CONTENT: True,
|
||||
Capability.CAN_VIEW_REPORTS: True,
|
||||
Capability.CAN_SUSPEND_USERS: True,
|
||||
Capability.CAN_APPROVE_PROVIDER: True,
|
||||
Capability.CAN_VIEW_ORG_STATS: True,
|
||||
Capability.CAN_MANAGE_DISCOUNTS: True,
|
||||
Capability.CAN_MANAGE_PROMOTIONS: True,
|
||||
Capability.CAN_VIEW_SALES_PIPELINE: True,
|
||||
Capability.CAN_EDIT_MASTER_TAXONOMY: True,
|
||||
Capability.CAN_MANAGE_SERVICE_CATEGORIES: True,
|
||||
Capability.CAN_VERIFY_SERVICE_PROVIDERS: True,
|
||||
Capability.CAN_MANAGE_EXPERTISE_TAGS: True,
|
||||
Capability.CAN_MANAGE_VEHICLE_CATALOG: True,
|
||||
Capability.CAN_APPROVE_VEHICLE_DEFINITIONS: True,
|
||||
Capability.CAN_VIEW_TECHNICAL_SPECS: True,
|
||||
Capability.CAN_VIEW_FINANCIAL_REPORTS: True,
|
||||
Capability.CAN_MANAGE_BILLING: False,
|
||||
Capability.CAN_ISSUE_REFUNDS: False,
|
||||
Capability.CAN_MANAGE_SUBSCRIPTIONS: True,
|
||||
Capability.CAN_MANAGE_OWN_PROFILE: True,
|
||||
Capability.CAN_MANAGE_OWN_VEHICLES: True,
|
||||
Capability.CAN_CREATE_SERVICE_REQUEST: True,
|
||||
Capability.CAN_VIEW_PUBLIC_CATALOG: True,
|
||||
},
|
||||
|
||||
# ── MODERATOR: Content moderation ──
|
||||
"MODERATOR": {
|
||||
Capability.CAN_MANAGE_USERS: False,
|
||||
Capability.CAN_MANAGE_ROLES: False,
|
||||
Capability.CAN_VIEW_SYSTEM_LOGS: False,
|
||||
Capability.CAN_MANAGE_SYSTEM_CONFIG: False,
|
||||
Capability.CAN_MANAGE_TRANSLATIONS: True,
|
||||
Capability.CAN_MODERATE_CONTENT: True,
|
||||
Capability.CAN_VIEW_REPORTS: True,
|
||||
Capability.CAN_SUSPEND_USERS: False,
|
||||
Capability.CAN_APPROVE_PROVIDER: True,
|
||||
Capability.CAN_VIEW_ORG_STATS: False,
|
||||
Capability.CAN_MANAGE_DISCOUNTS: False,
|
||||
Capability.CAN_MANAGE_PROMOTIONS: False,
|
||||
Capability.CAN_VIEW_SALES_PIPELINE: False,
|
||||
Capability.CAN_EDIT_MASTER_TAXONOMY: False,
|
||||
Capability.CAN_MANAGE_SERVICE_CATEGORIES: True,
|
||||
Capability.CAN_VERIFY_SERVICE_PROVIDERS: True,
|
||||
Capability.CAN_MANAGE_EXPERTISE_TAGS: True,
|
||||
Capability.CAN_MANAGE_VEHICLE_CATALOG: False,
|
||||
Capability.CAN_APPROVE_VEHICLE_DEFINITIONS: False,
|
||||
Capability.CAN_VIEW_TECHNICAL_SPECS: True,
|
||||
Capability.CAN_VIEW_FINANCIAL_REPORTS: False,
|
||||
Capability.CAN_MANAGE_BILLING: False,
|
||||
Capability.CAN_ISSUE_REFUNDS: False,
|
||||
Capability.CAN_MANAGE_OWN_PROFILE: True,
|
||||
Capability.CAN_MANAGE_OWN_VEHICLES: True,
|
||||
Capability.CAN_CREATE_SERVICE_REQUEST: True,
|
||||
Capability.CAN_VIEW_PUBLIC_CATALOG: True,
|
||||
},
|
||||
|
||||
# ── SALES_REP: Sales & discount management ──
|
||||
"SALES_REP": {
|
||||
Capability.CAN_MANAGE_USERS: False,
|
||||
Capability.CAN_MANAGE_ROLES: False,
|
||||
Capability.CAN_VIEW_SYSTEM_LOGS: False,
|
||||
Capability.CAN_MANAGE_SYSTEM_CONFIG: False,
|
||||
Capability.CAN_MANAGE_TRANSLATIONS: False,
|
||||
Capability.CAN_MODERATE_CONTENT: False,
|
||||
Capability.CAN_VIEW_REPORTS: False,
|
||||
Capability.CAN_SUSPEND_USERS: False,
|
||||
Capability.CAN_APPROVE_PROVIDER: False,
|
||||
Capability.CAN_VIEW_ORG_STATS: True,
|
||||
Capability.CAN_MANAGE_DISCOUNTS: True,
|
||||
Capability.CAN_MANAGE_PROMOTIONS: True,
|
||||
Capability.CAN_VIEW_SALES_PIPELINE: True,
|
||||
Capability.CAN_EDIT_MASTER_TAXONOMY: False,
|
||||
Capability.CAN_MANAGE_SERVICE_CATEGORIES: False,
|
||||
Capability.CAN_VERIFY_SERVICE_PROVIDERS: False,
|
||||
Capability.CAN_MANAGE_EXPERTISE_TAGS: False,
|
||||
Capability.CAN_MANAGE_VEHICLE_CATALOG: False,
|
||||
Capability.CAN_APPROVE_VEHICLE_DEFINITIONS: False,
|
||||
Capability.CAN_VIEW_TECHNICAL_SPECS: False,
|
||||
Capability.CAN_VIEW_FINANCIAL_REPORTS: False,
|
||||
Capability.CAN_MANAGE_BILLING: False,
|
||||
Capability.CAN_ISSUE_REFUNDS: False,
|
||||
Capability.CAN_MANAGE_OWN_PROFILE: True,
|
||||
Capability.CAN_MANAGE_OWN_VEHICLES: True,
|
||||
Capability.CAN_CREATE_SERVICE_REQUEST: True,
|
||||
Capability.CAN_VIEW_PUBLIC_CATALOG: True,
|
||||
},
|
||||
|
||||
# ── SERVICE_MGR: Service provider & taxonomy management ──
|
||||
"SERVICE_MGR": {
|
||||
Capability.CAN_MANAGE_USERS: False,
|
||||
Capability.CAN_MANAGE_ROLES: False,
|
||||
Capability.CAN_VIEW_SYSTEM_LOGS: False,
|
||||
Capability.CAN_MANAGE_SYSTEM_CONFIG: False,
|
||||
Capability.CAN_MANAGE_TRANSLATIONS: False,
|
||||
Capability.CAN_MODERATE_CONTENT: False,
|
||||
Capability.CAN_VIEW_REPORTS: False,
|
||||
Capability.CAN_SUSPEND_USERS: False,
|
||||
Capability.CAN_APPROVE_PROVIDER: True,
|
||||
Capability.CAN_VIEW_ORG_STATS: False,
|
||||
Capability.CAN_MANAGE_DISCOUNTS: False,
|
||||
Capability.CAN_MANAGE_PROMOTIONS: False,
|
||||
Capability.CAN_VIEW_SALES_PIPELINE: False,
|
||||
Capability.CAN_EDIT_MASTER_TAXONOMY: True,
|
||||
Capability.CAN_MANAGE_SERVICE_CATEGORIES: True,
|
||||
Capability.CAN_VERIFY_SERVICE_PROVIDERS: True,
|
||||
Capability.CAN_MANAGE_EXPERTISE_TAGS: True,
|
||||
Capability.CAN_MANAGE_VEHICLE_CATALOG: False,
|
||||
Capability.CAN_APPROVE_VEHICLE_DEFINITIONS: False,
|
||||
Capability.CAN_VIEW_TECHNICAL_SPECS: True,
|
||||
Capability.CAN_VIEW_FINANCIAL_REPORTS: False,
|
||||
Capability.CAN_MANAGE_BILLING: False,
|
||||
Capability.CAN_ISSUE_REFUNDS: False,
|
||||
Capability.CAN_MANAGE_OWN_PROFILE: True,
|
||||
Capability.CAN_MANAGE_OWN_VEHICLES: True,
|
||||
Capability.CAN_CREATE_SERVICE_REQUEST: True,
|
||||
Capability.CAN_VIEW_PUBLIC_CATALOG: True,
|
||||
},
|
||||
|
||||
# ── USER: Basic platform user ──
|
||||
"USER": {
|
||||
Capability.CAN_MANAGE_USERS: False,
|
||||
Capability.CAN_MANAGE_ROLES: False,
|
||||
Capability.CAN_VIEW_SYSTEM_LOGS: False,
|
||||
Capability.CAN_MANAGE_SYSTEM_CONFIG: False,
|
||||
Capability.CAN_MANAGE_TRANSLATIONS: False,
|
||||
Capability.CAN_MODERATE_CONTENT: False,
|
||||
Capability.CAN_VIEW_REPORTS: False,
|
||||
Capability.CAN_SUSPEND_USERS: False,
|
||||
Capability.CAN_APPROVE_PROVIDER: False,
|
||||
Capability.CAN_VIEW_ORG_STATS: False,
|
||||
Capability.CAN_MANAGE_DISCOUNTS: False,
|
||||
Capability.CAN_MANAGE_PROMOTIONS: False,
|
||||
Capability.CAN_VIEW_SALES_PIPELINE: False,
|
||||
Capability.CAN_EDIT_MASTER_TAXONOMY: False,
|
||||
Capability.CAN_MANAGE_SERVICE_CATEGORIES: False,
|
||||
Capability.CAN_VERIFY_SERVICE_PROVIDERS: False,
|
||||
Capability.CAN_MANAGE_EXPERTISE_TAGS: False,
|
||||
Capability.CAN_MANAGE_VEHICLE_CATALOG: False,
|
||||
Capability.CAN_APPROVE_VEHICLE_DEFINITIONS: False,
|
||||
Capability.CAN_VIEW_TECHNICAL_SPECS: False,
|
||||
Capability.CAN_VIEW_FINANCIAL_REPORTS: False,
|
||||
Capability.CAN_MANAGE_BILLING: False,
|
||||
Capability.CAN_ISSUE_REFUNDS: False,
|
||||
Capability.CAN_MANAGE_OWN_PROFILE: True,
|
||||
Capability.CAN_MANAGE_OWN_VEHICLES: True,
|
||||
Capability.CAN_CREATE_SERVICE_REQUEST: True,
|
||||
Capability.CAN_VIEW_PUBLIC_CATALOG: True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_capabilities_for_role(role_key: str) -> Dict[str, bool]:
|
||||
"""Return the capability dict for a given role key.
|
||||
|
||||
Args:
|
||||
role_key: The role key (e.g. 'SUPERADMIN', 'ADMIN', 'USER')
|
||||
|
||||
Returns:
|
||||
Dict of capability -> bool, or empty dict if role not found.
|
||||
"""
|
||||
return SYSTEM_CAPABILITIES_MATRIX.get(role_key.upper(), {})
|
||||
|
||||
|
||||
def role_has_capability(role_key: str, capability: str) -> bool:
|
||||
"""Check if a role has a specific capability.
|
||||
|
||||
Args:
|
||||
role_key: The role key (e.g. 'SUPERADMIN', 'ADMIN')
|
||||
capability: The capability key (e.g. 'can_manage_users')
|
||||
|
||||
Returns:
|
||||
True if the role has the capability, False otherwise.
|
||||
"""
|
||||
caps = get_capabilities_for_role(role_key)
|
||||
return caps.get(capability, False)
|
||||
|
||||
@@ -44,6 +44,7 @@ from .identity.social import ServiceProvider, Vote, Competition, UserScore, Serv
|
||||
from .gamification.gamification import PointRule, LevelConfig, UserStats, Badge, UserBadge, PointsLedger, UserContribution, Season
|
||||
|
||||
from .system.system import SystemParameter, ParameterScope, InternalNotification, SystemServiceStaging
|
||||
from .system.rbac import SystemRole, SystemPermission, SystemRolePermission
|
||||
|
||||
from .system.document import Document
|
||||
from .system.translation import Translation
|
||||
@@ -96,4 +97,6 @@ __all__ = [
|
||||
"Campaign", "Creative", "Placement", "CampaignCreative",
|
||||
"CampaignPlacement", "AdImpression", "AdClick",
|
||||
"CampaignStatus", "CreativeType", "PlacementType",
|
||||
# RBAC Phase 1
|
||||
"SystemRole", "SystemPermission", "SystemRolePermission",
|
||||
]
|
||||
|
||||
@@ -20,6 +20,7 @@ if TYPE_CHECKING:
|
||||
from .payment import PaymentIntent, WithdrawalRequest
|
||||
from .social import ServiceReview, SocialAccount
|
||||
from ..marketplace.service_request import ServiceRequest
|
||||
from ..system.rbac import SystemRole
|
||||
|
||||
class UserRole(str, enum.Enum):
|
||||
"""Rendszerszintű (System-level) szerepkörök.
|
||||
@@ -132,6 +133,15 @@ class User(Base):
|
||||
email: Mapped[str] = mapped_column(String, unique=True, index=True, nullable=False)
|
||||
hashed_password: Mapped[Optional[str]] = mapped_column(String)
|
||||
|
||||
# RBAC Phase 1: DB-driven role FK (nullable initially for migration safety)
|
||||
role_id: Mapped[Optional[int]] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("system.roles.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True
|
||||
)
|
||||
|
||||
# Legacy: Keep UserRole enum column for backward compatibility during migration
|
||||
role: Mapped[UserRole] = mapped_column(
|
||||
PG_ENUM(UserRole, name="userrole", schema="identity"),
|
||||
default=UserRole.USER
|
||||
@@ -188,10 +198,17 @@ class User(Base):
|
||||
|
||||
# --- KAPCSOLATOK ---
|
||||
|
||||
# RBAC Phase 1: DB-driven role relationship
|
||||
system_role: Mapped[Optional["SystemRole"]] = relationship(
|
||||
"SystemRole",
|
||||
foreign_keys=[role_id],
|
||||
lazy="joined"
|
||||
)
|
||||
|
||||
# JAVÍTÁS 4: Itt is explicit megadjuk, hogy melyik kulcs köti az emberhez
|
||||
person: Mapped[Optional["Person"]] = relationship(
|
||||
"Person",
|
||||
foreign_keys=[person_id],
|
||||
"Person",
|
||||
foreign_keys=[person_id],
|
||||
back_populates="users"
|
||||
)
|
||||
|
||||
|
||||
@@ -29,12 +29,15 @@ class OrgUserRole(str, enum.Enum):
|
||||
|
||||
Ezek a szerepkörök a szervezeten/flottán belüli jogosultságokat határozzák meg.
|
||||
Minden szerepkörhöz egyedi JSON permissions objektum tartozik a fleet.org_roles táblában.
|
||||
|
||||
Megjegyzés: Az értékek szinkronban kell lenniük a PostgreSQL fleet.orguserrole enum típussal.
|
||||
Jelenlegi DB enum: OWNER, ADMIN, MANAGER, MEMBER, AGENT
|
||||
"""
|
||||
OWNER = "OWNER"
|
||||
ADMIN = "ADMIN"
|
||||
ACCOUNTANT = "ACCOUNTANT"
|
||||
DRIVER = "DRIVER"
|
||||
VIEWER = "VIEWER"
|
||||
MANAGER = "MANAGER"
|
||||
MEMBER = "MEMBER"
|
||||
AGENT = "AGENT"
|
||||
|
||||
class OrgRole(Base):
|
||||
"""
|
||||
|
||||
@@ -4,9 +4,11 @@ from .audit import SecurityAuditLog, OperationalLog, ProcessLog, FinancialLedger
|
||||
from .document import Document
|
||||
from .translation import Translation
|
||||
from .legal import LegalDocument, LegalAcceptance
|
||||
from .rbac import SystemRole, SystemPermission, SystemRolePermission
|
||||
|
||||
__all__ = [
|
||||
"SystemParameter", "InternalNotification", "SystemServiceStaging",
|
||||
"SecurityAuditLog", "ProcessLog", "FinancialLedger", "WalletType", "LedgerStatus", "LedgerEntryType",
|
||||
"Document", "Translation", "LegalDocument", "LegalAcceptance"
|
||||
"Document", "Translation", "LegalDocument", "LegalAcceptance",
|
||||
"SystemRole", "SystemPermission", "SystemRolePermission",
|
||||
]
|
||||
|
||||
135
backend/app/models/system/rbac.py
Normal file
135
backend/app/models/system/rbac.py
Normal file
@@ -0,0 +1,135 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/models/system/rbac.py
|
||||
"""
|
||||
RBAC Phase 1: Database-Driven Role-Based Access Control models.
|
||||
|
||||
These models replace the hardcoded SYSTEM_CAPABILITIES_MATRIX and UserRole enum
|
||||
with fully database-driven roles, permissions, and role-permission mappings.
|
||||
|
||||
Schema: system
|
||||
Tables:
|
||||
- system.roles
|
||||
- system.permissions
|
||||
- system.role_permissions
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, TYPE_CHECKING
|
||||
from sqlalchemy import String, Integer, Boolean, DateTime, ForeignKey, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
|
||||
class SystemRole(Base):
|
||||
"""
|
||||
System-level roles for the entire platform.
|
||||
|
||||
Replaces the hardcoded UserRole enum. Each role has a hierarchical rank
|
||||
(100=SUPERADMIN, 0=USER) for quick privilege comparisons.
|
||||
System roles are protected (is_system=True) and cannot be deleted via admin UI.
|
||||
"""
|
||||
__tablename__ = "roles"
|
||||
__table_args__ = (
|
||||
{"schema": "system", "extend_existing": True}
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
|
||||
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
rank: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
is_system: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True), onupdate=func.now(), nullable=True
|
||||
)
|
||||
|
||||
# --- RELATIONSHIPS ---
|
||||
permissions: Mapped[List["SystemRolePermission"]] = relationship(
|
||||
"SystemRolePermission", back_populates="role", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<SystemRole(id={self.id}, name='{self.name}', rank={self.rank})>"
|
||||
|
||||
|
||||
class SystemPermission(Base):
|
||||
"""
|
||||
Granular permission codes for the entire platform.
|
||||
|
||||
Format: domain:action (e.g., fleet:view, user:create, finance:approve)
|
||||
|
||||
Domains: fleet, user, finance, system, org, reports, audit, settings
|
||||
Actions: view, create, edit, delete, approve, manage, export, refund, manage-roles
|
||||
"""
|
||||
__tablename__ = "permissions"
|
||||
__table_args__ = (
|
||||
{"schema": "system", "extend_existing": True}
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
code: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
|
||||
domain: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
action: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
is_system: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
# --- RELATIONSHIPS ---
|
||||
role_assignments: Mapped[List["SystemRolePermission"]] = relationship(
|
||||
"SystemRolePermission", back_populates="permission", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<SystemPermission(id={self.id}, code='{self.code}')>"
|
||||
|
||||
|
||||
class SystemRolePermission(Base):
|
||||
"""
|
||||
Many-to-many mapping between roles and permissions with explicit grant/deny.
|
||||
|
||||
The `granted` boolean allows explicit denial of a permission for a role,
|
||||
which can override inherited permissions in hierarchical role structures.
|
||||
"""
|
||||
__tablename__ = "role_permissions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("role_id", "permission_id", name="uix_role_permission"),
|
||||
{"schema": "system", "extend_existing": True}
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
role_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("system.roles.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
permission_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("system.permissions.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
granted: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
# --- RELATIONSHIPS ---
|
||||
role: Mapped["SystemRole"] = relationship("SystemRole", back_populates="permissions")
|
||||
permission: Mapped["SystemPermission"] = relationship(
|
||||
"SystemPermission", back_populates="role_assignments"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<SystemRolePermission(id={self.id}, "
|
||||
f"role_id={self.role_id}, "
|
||||
f"permission_id={self.permission_id}, "
|
||||
f"granted={self.granted})>"
|
||||
)
|
||||
@@ -1,7 +1,79 @@
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
from pydantic import BaseModel, Field, ConfigDict, model_validator
|
||||
from typing import Optional, List, Any
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Organization Member Schemas (P0: Full CRUD for Garage Employees)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class OrganizationMemberBase(BaseModel):
|
||||
"""Base schema for organization member data."""
|
||||
user_id: int = Field(..., description="The user ID to add as a member")
|
||||
role: str = Field(default="MEMBER", description="Role within the organization. Valid values: OWNER, ADMIN, MANAGER, MEMBER, AGENT (PG_ENUM orguserrole)")
|
||||
status: str = Field(default="active", description="Membership status (active, inactive, archived)")
|
||||
|
||||
|
||||
class OrganizationMemberCreate(OrganizationMemberBase):
|
||||
"""Schema for adding a new member to a garage."""
|
||||
pass
|
||||
|
||||
|
||||
class OrganizationMemberUpdate(BaseModel):
|
||||
"""Schema for updating an existing member's role/status."""
|
||||
role: Optional[str] = Field(default=None, description="New role within the organization")
|
||||
status: Optional[str] = Field(default=None, description="New membership status (active, inactive, archived)")
|
||||
|
||||
|
||||
class PersonBrief(BaseModel):
|
||||
"""Nested person data for member responses."""
|
||||
first_name: str = ""
|
||||
last_name: str = ""
|
||||
email: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class OrganizationMemberResponse(BaseModel):
|
||||
"""Full member data returned by the API."""
|
||||
id: int
|
||||
user_id: Optional[int] = None
|
||||
organization_id: int
|
||||
role: str
|
||||
status: str = "active"
|
||||
is_verified: bool = False
|
||||
person: Optional[PersonBrief] = None
|
||||
created_at: Optional[str] = None
|
||||
updated_at: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def coerce_enums_to_strings(cls, data: Any) -> Any:
|
||||
"""Convert PG_ENUM / Enum values to plain strings before Pydantic validation.
|
||||
|
||||
The OrganizationMember.role field uses a PostgreSQL ENUM type (OrgUserRole).
|
||||
When loaded via SQLAlchemy's from_attributes, the enum instance is not
|
||||
automatically serialized to a string. This validator ensures any enum value
|
||||
is converted to its string representation.
|
||||
"""
|
||||
if isinstance(data, dict):
|
||||
for field_name in ("role", "status"):
|
||||
val = data.get(field_name)
|
||||
if val is not None and not isinstance(val, str):
|
||||
# Handle PG_ENUM / Python enum instances
|
||||
data[field_name] = str(val.value if hasattr(val, "value") else val)
|
||||
return data
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Contact / Onboarding Schemas
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ContactCreate(BaseModel):
|
||||
full_name: str
|
||||
email: str
|
||||
|
||||
385
backend/app/scripts/seed_rbac.py
Normal file
385
backend/app/scripts/seed_rbac.py
Normal file
@@ -0,0 +1,385 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/scripts/seed_rbac.py
|
||||
"""
|
||||
RBAC Phase 1: Seed script for database-driven Role-Based Access Control.
|
||||
|
||||
This script initializes the database with:
|
||||
- 6 system roles (SUPERADMIN, ADMIN, MODERATOR, SALES_REP, SERVICE_MGR, USER)
|
||||
- 28 permission codes across 8 domains
|
||||
- Role-permission mappings according to the audit proposal matrix
|
||||
- User migration: maps existing UserRole enum values to new role_id FK
|
||||
|
||||
Usage:
|
||||
docker exec sf_api python -m app.scripts.seed_rbac
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
from sqlalchemy import select, text, update
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.system.rbac import SystemRole, SystemPermission, SystemRolePermission
|
||||
from app.models.identity.identity import User, UserRole
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s]: %(message)s')
|
||||
logger = logging.getLogger("Seed-RBAC")
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# STEP A: 6 System Roles
|
||||
# ──────────────────────────────────────────────
|
||||
ROLES = [
|
||||
{"name": "SUPERADMIN", "description": "Platform super administrator with full system access", "rank": 100, "is_system": True},
|
||||
{"name": "ADMIN", "description": "Platform administrator with elevated management privileges", "rank": 80, "is_system": True},
|
||||
{"name": "MODERATOR", "description": "Content moderator with limited administrative access", "rank": 60, "is_system": True},
|
||||
{"name": "SALES_REP", "description": "Sales representative with fleet creation privileges", "rank": 40, "is_system": True},
|
||||
{"name": "SERVICE_MGR", "description": "Service manager with organization-level access", "rank": 20, "is_system": True},
|
||||
{"name": "USER", "description": "Regular platform user with basic access", "rank": 0, "is_system": True},
|
||||
]
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# STEP B: 28 Permission Codes
|
||||
# ──────────────────────────────────────────────
|
||||
PERMISSIONS = [
|
||||
# fleet domain (6 permissions)
|
||||
{"code": "fleet:view", "domain": "fleet", "action": "view", "description": "View fleet/vehicle data"},
|
||||
{"code": "fleet:create", "domain": "fleet", "action": "create", "description": "Create new fleet/vehicle entries"},
|
||||
{"code": "fleet:edit", "domain": "fleet", "action": "edit", "description": "Edit existing fleet/vehicle data"},
|
||||
{"code": "fleet:delete", "domain": "fleet", "action": "delete", "description": "Delete fleet/vehicle entries"},
|
||||
{"code": "fleet:approve", "domain": "fleet", "action": "approve","description": "Approve fleet/vehicle submissions"},
|
||||
|
||||
# user domain (5 permissions)
|
||||
{"code": "user:view", "domain": "user", "action": "view", "description": "View user profiles and data"},
|
||||
{"code": "user:create", "domain": "user", "action": "create", "description": "Create new user accounts"},
|
||||
{"code": "user:edit", "domain": "user", "action": "edit", "description": "Edit user profiles and data"},
|
||||
{"code": "user:delete", "domain": "user", "action": "delete", "description": "Delete user accounts"},
|
||||
{"code": "user:manage-roles", "domain": "user", "action": "manage-roles","description": "Manage user role assignments"},
|
||||
|
||||
# finance domain (4 permissions)
|
||||
{"code": "finance:view", "domain": "finance", "action": "view", "description": "View financial data and transactions"},
|
||||
{"code": "finance:edit", "domain": "finance", "action": "edit", "description": "Edit financial records"},
|
||||
{"code": "finance:approve", "domain": "finance", "action": "approve", "description": "Approve financial transactions"},
|
||||
{"code": "finance:refund", "domain": "finance", "action": "refund", "description": "Process refunds"},
|
||||
|
||||
# system domain (3 permissions)
|
||||
{"code": "system:view", "domain": "system", "action": "view", "description": "View system configuration"},
|
||||
{"code": "system:edit", "domain": "system", "action": "edit", "description": "Edit system configuration"},
|
||||
{"code": "system:manage", "domain": "system", "action": "manage", "description": "Full system management access"},
|
||||
|
||||
# org domain (4 permissions)
|
||||
{"code": "org:view", "domain": "org", "action": "view", "description": "View organization data"},
|
||||
{"code": "org:edit", "domain": "org", "action": "edit", "description": "Edit organization data"},
|
||||
{"code": "org:delete", "domain": "org", "action": "delete", "description": "Delete organizations"},
|
||||
{"code": "org:manage-members", "domain": "org", "action": "manage-members","description": "Manage organization members"},
|
||||
|
||||
# reports domain (3 permissions)
|
||||
{"code": "reports:view", "domain": "reports", "action": "view", "description": "View reports"},
|
||||
{"code": "reports:export", "domain": "reports", "action": "export", "description": "Export reports"},
|
||||
{"code": "reports:manage", "domain": "reports", "action": "manage", "description": "Manage report configurations"},
|
||||
|
||||
# audit domain (2 permissions)
|
||||
{"code": "audit:view", "domain": "audit", "action": "view", "description": "View audit logs"},
|
||||
{"code": "audit:export", "domain": "audit", "action": "export", "description": "Export audit logs"},
|
||||
|
||||
# settings domain (2 permissions)
|
||||
{"code": "settings:view", "domain": "settings", "action": "view", "description": "View system settings"},
|
||||
{"code": "settings:edit", "domain": "settings", "action": "edit", "description": "Edit system settings"},
|
||||
]
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# STEP C: Role-Permission Mapping Matrix
|
||||
# Format: { "role_name": {"permission_code": True/False} }
|
||||
# True = granted, False = denied (explicit)
|
||||
# ──────────────────────────────────────────────
|
||||
ROLE_PERMISSION_MATRIX = {
|
||||
"SUPERADMIN": {
|
||||
# fleet - all granted
|
||||
"fleet:view": True, "fleet:create": True, "fleet:edit": True,
|
||||
"fleet:delete": True, "fleet:approve": True,
|
||||
# user - all granted
|
||||
"user:view": True, "user:create": True, "user:edit": True,
|
||||
"user:delete": True, "user:manage-roles": True,
|
||||
# finance - all granted
|
||||
"finance:view": True, "finance:edit": True,
|
||||
"finance:approve": True, "finance:refund": True,
|
||||
# system - all granted
|
||||
"system:view": True, "system:edit": True, "system:manage": True,
|
||||
# org - all granted
|
||||
"org:view": True, "org:edit": True, "org:delete": True, "org:manage-members": True,
|
||||
# reports - all granted
|
||||
"reports:view": True, "reports:export": True, "reports:manage": True,
|
||||
# audit - all granted
|
||||
"audit:view": True, "audit:export": True,
|
||||
# settings - all granted
|
||||
"settings:view": True, "settings:edit": True,
|
||||
},
|
||||
"ADMIN": {
|
||||
# fleet
|
||||
"fleet:view": True, "fleet:create": True, "fleet:edit": True,
|
||||
"fleet:delete": False, "fleet:approve": True,
|
||||
# user
|
||||
"user:view": True, "user:create": True, "user:edit": True,
|
||||
"user:delete": False, "user:manage-roles": False,
|
||||
# finance
|
||||
"finance:view": True, "finance:edit": True,
|
||||
"finance:approve": False, "finance:refund": False,
|
||||
# system
|
||||
"system:view": True, "system:edit": True, "system:manage": False,
|
||||
# org
|
||||
"org:view": True, "org:edit": True, "org:delete": False, "org:manage-members": True,
|
||||
# reports
|
||||
"reports:view": True, "reports:export": True, "reports:manage": False,
|
||||
# audit
|
||||
"audit:view": False, "audit:export": False,
|
||||
# settings
|
||||
"settings:view": True, "settings:edit": True,
|
||||
},
|
||||
"MODERATOR": {
|
||||
# fleet
|
||||
"fleet:view": True, "fleet:create": False, "fleet:edit": False,
|
||||
"fleet:delete": False, "fleet:approve": False,
|
||||
# user
|
||||
"user:view": True, "user:create": False, "user:edit": False,
|
||||
"user:delete": False, "user:manage-roles": False,
|
||||
# finance
|
||||
"finance:view": True, "finance:edit": False,
|
||||
"finance:approve": False, "finance:refund": False,
|
||||
# system
|
||||
"system:view": True, "system:edit": False, "system:manage": False,
|
||||
# org
|
||||
"org:view": True, "org:edit": False, "org:delete": False, "org:manage-members": False,
|
||||
# reports
|
||||
"reports:view": True, "reports:export": True, "reports:manage": False,
|
||||
# audit
|
||||
"audit:view": False, "audit:export": False,
|
||||
# settings
|
||||
"settings:view": False, "settings:edit": False,
|
||||
},
|
||||
"SALES_REP": {
|
||||
# fleet
|
||||
"fleet:view": True, "fleet:create": True, "fleet:edit": False,
|
||||
"fleet:delete": False, "fleet:approve": False,
|
||||
# user
|
||||
"user:view": False, "user:create": False, "user:edit": False,
|
||||
"user:delete": False, "user:manage-roles": False,
|
||||
# finance
|
||||
"finance:view": False, "finance:edit": False,
|
||||
"finance:approve": False, "finance:refund": False,
|
||||
# system
|
||||
"system:view": False, "system:edit": False, "system:manage": False,
|
||||
# org
|
||||
"org:view": True, "org:edit": False, "org:delete": False, "org:manage-members": False,
|
||||
# reports
|
||||
"reports:view": True, "reports:export": False, "reports:manage": False,
|
||||
# audit
|
||||
"audit:view": False, "audit:export": False,
|
||||
# settings
|
||||
"settings:view": False, "settings:edit": False,
|
||||
},
|
||||
"SERVICE_MGR": {
|
||||
# fleet
|
||||
"fleet:view": True, "fleet:create": False, "fleet:edit": False,
|
||||
"fleet:delete": False, "fleet:approve": False,
|
||||
# user
|
||||
"user:view": False, "user:create": False, "user:edit": False,
|
||||
"user:delete": False, "user:manage-roles": False,
|
||||
# finance
|
||||
"finance:view": False, "finance:edit": False,
|
||||
"finance:approve": False, "finance:refund": False,
|
||||
# system
|
||||
"system:view": False, "system:edit": False, "system:manage": False,
|
||||
# org
|
||||
"org:view": True, "org:edit": False, "org:delete": False, "org:manage-members": False,
|
||||
# reports
|
||||
"reports:view": True, "reports:export": False, "reports:manage": False,
|
||||
# audit
|
||||
"audit:view": False, "audit:export": False,
|
||||
# settings
|
||||
"settings:view": False, "settings:edit": False,
|
||||
},
|
||||
"USER": {
|
||||
# fleet
|
||||
"fleet:view": True, "fleet:create": False, "fleet:edit": False,
|
||||
"fleet:delete": False, "fleet:approve": False,
|
||||
# user
|
||||
"user:view": False, "user:create": False, "user:edit": False,
|
||||
"user:delete": False, "user:manage-roles": False,
|
||||
# finance
|
||||
"finance:view": False, "finance:edit": False,
|
||||
"finance:approve": False, "finance:refund": False,
|
||||
# system
|
||||
"system:view": False, "system:edit": False, "system:manage": False,
|
||||
# org
|
||||
"org:view": False, "org:edit": False, "org:delete": False, "org:manage-members": False,
|
||||
# reports
|
||||
"reports:view": False, "reports:export": False, "reports:manage": False,
|
||||
# audit
|
||||
"audit:view": False, "audit:export": False,
|
||||
# settings
|
||||
"settings:view": False, "settings:edit": False,
|
||||
},
|
||||
}
|
||||
|
||||
# Map UserRole enum values to role names
|
||||
USERROLE_TO_ROLE_NAME = {
|
||||
UserRole.SUPERADMIN: "SUPERADMIN",
|
||||
UserRole.ADMIN: "ADMIN",
|
||||
UserRole.MODERATOR: "MODERATOR",
|
||||
UserRole.SALES_REP: "SALES_REP",
|
||||
UserRole.SERVICE_MGR: "SERVICE_MGR",
|
||||
UserRole.USER: "USER",
|
||||
}
|
||||
|
||||
|
||||
async def seed_rbac():
|
||||
"""Main seed function: creates roles, permissions, mappings, and migrates users."""
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
# ── STEP A: Insert Roles ──
|
||||
logger.info("=" * 60)
|
||||
logger.info("STEP A: Inserting 6 system roles...")
|
||||
role_map = {} # name -> id
|
||||
for role_data in ROLES:
|
||||
existing = await db.execute(
|
||||
select(SystemRole).where(SystemRole.name == role_data["name"])
|
||||
)
|
||||
role = existing.scalar_one_or_none()
|
||||
if role:
|
||||
logger.info(f" ⏭️ Role '{role_data['name']}' already exists (id={role.id}), updating...")
|
||||
role.description = role_data["description"]
|
||||
role.rank = role_data["rank"]
|
||||
role.is_system = role_data["is_system"]
|
||||
else:
|
||||
role = SystemRole(**role_data)
|
||||
db.add(role)
|
||||
await db.flush()
|
||||
logger.info(f" ✅ Created role '{role_data['name']}' (id={role.id})")
|
||||
role_map[role_data["name"]] = role.id
|
||||
|
||||
await db.commit()
|
||||
logger.info(f" ✅ Roles committed. {len(role_map)} roles in role_map.")
|
||||
|
||||
# ── STEP B: Insert Permissions ──
|
||||
logger.info("=" * 60)
|
||||
logger.info("STEP B: Inserting 28 permission codes...")
|
||||
perm_map = {} # code -> id
|
||||
for perm_data in PERMISSIONS:
|
||||
existing = await db.execute(
|
||||
select(SystemPermission).where(SystemPermission.code == perm_data["code"])
|
||||
)
|
||||
perm = existing.scalar_one_or_none()
|
||||
if perm:
|
||||
logger.info(f" ⏭️ Permission '{perm_data['code']}' already exists (id={perm.id}), updating...")
|
||||
perm.domain = perm_data["domain"]
|
||||
perm.action = perm_data["action"]
|
||||
perm.description = perm_data["description"]
|
||||
else:
|
||||
perm = SystemPermission(**perm_data)
|
||||
db.add(perm)
|
||||
await db.flush()
|
||||
logger.info(f" ✅ Created permission '{perm_data['code']}' (id={perm.id})")
|
||||
perm_map[perm_data["code"]] = perm.id
|
||||
|
||||
await db.commit()
|
||||
logger.info(f" ✅ Permissions committed. {len(perm_map)} permissions in perm_map.")
|
||||
|
||||
# ── STEP C: Insert Role-Permission Mappings ──
|
||||
logger.info("=" * 60)
|
||||
logger.info("STEP C: Inserting role-permission mappings...")
|
||||
mapping_count = 0
|
||||
for role_name, perms in ROLE_PERMISSION_MATRIX.items():
|
||||
role_id = role_map.get(role_name)
|
||||
if not role_id:
|
||||
logger.warning(f" ⚠️ Role '{role_name}' not found in role_map, skipping!")
|
||||
continue
|
||||
|
||||
for perm_code, granted in perms.items():
|
||||
perm_id = perm_map.get(perm_code)
|
||||
if not perm_id:
|
||||
logger.warning(f" ⚠️ Permission '{perm_code}' not found in perm_map, skipping!")
|
||||
continue
|
||||
|
||||
# Check if mapping already exists
|
||||
existing = await db.execute(
|
||||
select(SystemRolePermission).where(
|
||||
SystemRolePermission.role_id == role_id,
|
||||
SystemRolePermission.permission_id == perm_id,
|
||||
)
|
||||
)
|
||||
rp = existing.scalar_one_or_none()
|
||||
if rp:
|
||||
if rp.granted != granted:
|
||||
rp.granted = granted
|
||||
logger.info(f" 🔄 Updated mapping: {role_name} -> {perm_code} (granted={granted})")
|
||||
else:
|
||||
rp = SystemRolePermission(
|
||||
role_id=role_id,
|
||||
permission_id=perm_id,
|
||||
granted=granted,
|
||||
)
|
||||
db.add(rp)
|
||||
mapping_count += 1
|
||||
|
||||
await db.commit()
|
||||
logger.info(f" ✅ {mapping_count} new role-permission mappings created.")
|
||||
|
||||
# ── STEP D: Migrate Existing Users ──
|
||||
logger.info("=" * 60)
|
||||
logger.info("STEP D: Migrating existing users to role_id...")
|
||||
|
||||
# Get all users that don't have role_id set yet
|
||||
result = await db.execute(
|
||||
select(User).where(User.role_id.is_(None))
|
||||
)
|
||||
users_to_migrate = result.scalars().all()
|
||||
|
||||
migrated_count = 0
|
||||
skipped_count = 0
|
||||
for user in users_to_migrate:
|
||||
role_name = USERROLE_TO_ROLE_NAME.get(user.role)
|
||||
if not role_name:
|
||||
logger.warning(f" ⚠️ User {user.id} has unknown role '{user.role}', skipping...")
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
role_id = role_map.get(role_name)
|
||||
if not role_id:
|
||||
logger.warning(f" ⚠️ Role '{role_name}' not found for user {user.id}, skipping...")
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
user.role_id = role_id
|
||||
migrated_count += 1
|
||||
|
||||
await db.commit()
|
||||
logger.info(f" ✅ Users migrated: {migrated_count} updated, {skipped_count} skipped.")
|
||||
|
||||
# ── FINAL REPORT ──
|
||||
logger.info("=" * 60)
|
||||
logger.info("📊 RBAC SEED SUMMARY")
|
||||
logger.info(f" Roles: {len(role_map)}")
|
||||
logger.info(f" Permissions: {len(perm_map)}")
|
||||
|
||||
# Count total role_permissions
|
||||
rp_result = await db.execute(select(SystemRolePermission))
|
||||
total_rp = len(rp_result.scalars().all())
|
||||
logger.info(f" Role-Permission Mappings: {total_rp}")
|
||||
|
||||
# Count users with role_id
|
||||
user_result = await db.execute(
|
||||
select(User).where(User.role_id.isnot(None))
|
||||
)
|
||||
users_with_role = len(user_result.scalars().all())
|
||||
logger.info(f" Users with role_id assigned: {users_with_role}")
|
||||
logger.info("=" * 60)
|
||||
logger.info("✅ RBAC Phase 1 seeding completed successfully!")
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"❌ RBAC seeding failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def main():
|
||||
logger.info("🚀 Starting RBAC Phase 1 seed...")
|
||||
await seed_rbac()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -94,7 +94,7 @@ async def seed_params():
|
||||
# --- 4. FINANCE (Költségek és Devizák) ---
|
||||
{"key": "finance_default_currency", "value": "HUF", "category": "finance", "description": "Helyi alap deviza", "scope_level": "global"},
|
||||
{"key": "finance_base_currency", "value": "EUR", "category": "finance", "description": "Központi elszámoló deviza (Statisztikákhoz)", "scope_level": "global"},
|
||||
{"key": "org_naming_template", "value": "{last_name} Flotta", "category": "system", "description": "Szervezet név sablon", "scope_level": "global"},
|
||||
{"key": "org_naming_template", "value": "{last_name} {first_name} - Privát Garázs (#{user_id})", "category": "system", "description": "Szervezet név sablon (Privát Garázs)", "scope_level": "global"},
|
||||
|
||||
# --- 5. DOCUMENT & OCR (Robot 1 vezérlése) ---
|
||||
{
|
||||
|
||||
@@ -247,10 +247,11 @@ class AuthService:
|
||||
|
||||
|
||||
# Dinamikus szervezet generálás
|
||||
org_full_name = org_tpl.format(last_name=p.last_name, first_name=p.first_name)
|
||||
# Naming convention: "{last_name} {first_name} - Privát Garázs (#{user_id})"
|
||||
org_full_name = f"{p.last_name} {p.first_name} - Privát Garázs (#{user.id})"
|
||||
new_org = Organization(
|
||||
full_name=org_full_name,
|
||||
name=f"{p.last_name} Garázsa",
|
||||
name=org_full_name,
|
||||
folder_slug=generate_secure_slug(12),
|
||||
org_type=OrgType.individual,
|
||||
owner_id=user.id,
|
||||
|
||||
@@ -31,7 +31,7 @@ from sqlalchemy import select
|
||||
|
||||
from app.models.identity import User, UserRole
|
||||
from app.models.marketplace.organization import Organization
|
||||
from app.core.capabilities import SYSTEM_CAPABILITIES_MATRIX, role_has_capability
|
||||
from app.models.system.rbac import SystemRolePermission, SystemPermission
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -43,88 +43,6 @@ class ScopeType(str, Enum):
|
||||
SEGMENT = "SEGMENT"
|
||||
|
||||
|
||||
class AdminAction(str, Enum):
|
||||
"""Admin actions that can be checked against scope."""
|
||||
# ── Data Management ──
|
||||
EDIT_DATA = "EDIT_DATA"
|
||||
VIEW_DATA = "VIEW_DATA"
|
||||
DELETE_DATA = "DELETE_DATA"
|
||||
EXPORT_DATA = "EXPORT_DATA"
|
||||
|
||||
# ── Subscription Management ──
|
||||
VIEW_SUBSCRIPTION = "VIEW_SUBSCRIPTION"
|
||||
EDIT_SUBSCRIPTION = "EDIT_SUBSCRIPTION"
|
||||
CANCEL_SUBSCRIPTION = "CANCEL_SUBSCRIPTION"
|
||||
|
||||
# ── User Management ──
|
||||
MANAGE_USERS = "MANAGE_USERS"
|
||||
SUSPEND_USERS = "SUSPEND_USERS"
|
||||
BAN_USERS = "BAN_USERS"
|
||||
|
||||
# ── Financial ──
|
||||
VIEW_FINANCIALS = "VIEW_FINANCIALS"
|
||||
MANAGE_BILLING = "MANAGE_BILLING"
|
||||
ISSUE_REFUNDS = "ISSUE_REFUNDS"
|
||||
|
||||
# ── Moderation ──
|
||||
APPROVE_CONTENT = "APPROVE_CONTENT"
|
||||
MODERATE_REVIEWS = "MODERATE_REVIEWS"
|
||||
VERIFY_PROVIDERS = "VERIFY_PROVIDERS"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# ACTION-TO-ROLE MAPPING
|
||||
# Which roles are permitted to perform which actions
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
# Actions that SUPERADMIN can always do (implicitly all)
|
||||
# Actions that ADMIN can do within their scope
|
||||
ADMIN_SCOPE_ACTIONS: Set[str] = {
|
||||
AdminAction.EDIT_DATA,
|
||||
AdminAction.VIEW_DATA,
|
||||
AdminAction.DELETE_DATA,
|
||||
AdminAction.EXPORT_DATA,
|
||||
AdminAction.VIEW_SUBSCRIPTION,
|
||||
AdminAction.EDIT_SUBSCRIPTION,
|
||||
AdminAction.MANAGE_USERS,
|
||||
AdminAction.SUSPEND_USERS,
|
||||
AdminAction.BAN_USERS,
|
||||
AdminAction.VIEW_FINANCIALS,
|
||||
AdminAction.MANAGE_BILLING,
|
||||
AdminAction.APPROVE_CONTENT,
|
||||
AdminAction.MODERATE_REVIEWS,
|
||||
AdminAction.VERIFY_PROVIDERS,
|
||||
}
|
||||
|
||||
# MODERATOR can do within their scope
|
||||
MODERATOR_SCOPE_ACTIONS: Set[str] = {
|
||||
AdminAction.VIEW_DATA,
|
||||
AdminAction.VIEW_SUBSCRIPTION,
|
||||
AdminAction.APPROVE_CONTENT,
|
||||
AdminAction.MODERATE_REVIEWS,
|
||||
AdminAction.VERIFY_PROVIDERS,
|
||||
AdminAction.SUSPEND_USERS,
|
||||
}
|
||||
|
||||
# SALES_REP can do within their scope
|
||||
SALES_REP_SCOPE_ACTIONS: Set[str] = {
|
||||
AdminAction.VIEW_DATA,
|
||||
AdminAction.VIEW_SUBSCRIPTION,
|
||||
AdminAction.EDIT_SUBSCRIPTION,
|
||||
AdminAction.VIEW_FINANCIALS,
|
||||
AdminAction.EXPORT_DATA,
|
||||
}
|
||||
|
||||
# SERVICE_MGR can do within their scope
|
||||
SERVICE_MGR_SCOPE_ACTIONS: Set[str] = {
|
||||
AdminAction.VIEW_DATA,
|
||||
AdminAction.EDIT_DATA,
|
||||
AdminAction.APPROVE_CONTENT,
|
||||
AdminAction.VERIFY_PROVIDERS,
|
||||
AdminAction.MODERATE_REVIEWS,
|
||||
}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# RBAC SERVICE
|
||||
# ──────────────────────────────────────────────
|
||||
@@ -136,22 +54,17 @@ class RBACService:
|
||||
Provides methods to check whether a staff user has access
|
||||
to perform a specific action on a target organization,
|
||||
based on the user's role and assigned scope.
|
||||
"""
|
||||
|
||||
# Mapping of role → set of permitted actions
|
||||
ROLE_ACTIONS: Dict[UserRole, Set[str]] = {
|
||||
UserRole.SUPERADMIN: set(AdminAction), # All actions
|
||||
UserRole.ADMIN: ADMIN_SCOPE_ACTIONS,
|
||||
UserRole.MODERATOR: MODERATOR_SCOPE_ACTIONS,
|
||||
UserRole.SALES_REP: SALES_REP_SCOPE_ACTIONS,
|
||||
UserRole.SERVICE_MGR: SERVICE_MGR_SCOPE_ACTIONS,
|
||||
}
|
||||
P0 Phase 6: All permission checks are now DB-driven via
|
||||
system.role_permissions. The legacy AdminAction enum has been
|
||||
removed. Use get_role_permissions() for all role lookups.
|
||||
"""
|
||||
|
||||
async def check_admin_access(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
action: Union[str, AdminAction],
|
||||
action: str,
|
||||
target_org_id: Optional[int] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
@@ -160,7 +73,7 @@ class RBACService:
|
||||
Args:
|
||||
db: Database session
|
||||
user: The staff user to check
|
||||
action: The action being attempted (AdminAction enum or string)
|
||||
action: The action being attempted (string code)
|
||||
target_org_id: Optional target organization ID for scope matching
|
||||
|
||||
Returns:
|
||||
@@ -169,17 +82,15 @@ class RBACService:
|
||||
Raises:
|
||||
PermissionError: If access is denied (with details)
|
||||
"""
|
||||
action_str = action.value if isinstance(action, AdminAction) else action
|
||||
|
||||
# ── 1. SUPERADMIN bypass ──
|
||||
if user.role == UserRole.SUPERADMIN:
|
||||
return True
|
||||
|
||||
# ── 2. Check if the role is permitted for this action ──
|
||||
permitted_actions = self.ROLE_ACTIONS.get(user.role, set())
|
||||
if action_str not in permitted_actions:
|
||||
permitted_actions = await self.get_permitted_actions(db, user.role)
|
||||
if action not in permitted_actions:
|
||||
raise PermissionError(
|
||||
f"Action '{action_str}' is not permitted for role '{user.role.value}'."
|
||||
f"Action '{action}' is not permitted for role '{user.role.value}'."
|
||||
)
|
||||
|
||||
# ── 3. If no target_org_id, just check action permission ──
|
||||
@@ -304,14 +215,98 @@ class RBACService:
|
||||
result = await db.execute(stmt)
|
||||
return [row[0] for row in result.all()]
|
||||
|
||||
def get_permitted_actions(self, role: UserRole) -> Set[str]:
|
||||
"""Get the set of actions permitted for a given role."""
|
||||
return self.ROLE_ACTIONS.get(role, set())
|
||||
async def get_permitted_actions(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
role: UserRole,
|
||||
) -> Set[str]:
|
||||
"""
|
||||
Get the set of permission codes permitted for a given role.
|
||||
|
||||
P0 Phase 6: Fully DB-driven via system.role_permissions.
|
||||
The role name is used to look up the system.roles.id, then
|
||||
all granted permission codes are returned.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
role: The UserRole enum value to look up
|
||||
|
||||
Returns:
|
||||
Set of permission code strings (e.g., {"fleet:view", "user:create"})
|
||||
"""
|
||||
# Look up the system.roles.id by name
|
||||
stmt = select(SystemRolePermission).limit(0) # just to get the table
|
||||
# Use raw SQL for the join to avoid circular import issues
|
||||
from sqlalchemy import text
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT p.code
|
||||
FROM system.role_permissions rp
|
||||
JOIN system.permissions p ON p.id = rp.permission_id
|
||||
JOIN system.roles r ON r.id = rp.role_id
|
||||
WHERE r.name = :role_name AND rp.granted = TRUE
|
||||
"""),
|
||||
{"role_name": role.value},
|
||||
)
|
||||
return {row[0] for row in result.all()}
|
||||
|
||||
def is_action_permitted(self, role: UserRole, action: Union[str, AdminAction]) -> bool:
|
||||
async def is_action_permitted(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
role: UserRole,
|
||||
action: str,
|
||||
) -> bool:
|
||||
"""Check if a specific action is permitted for a given role."""
|
||||
action_str = action.value if isinstance(action, AdminAction) else action
|
||||
return action_str in self.ROLE_ACTIONS.get(role, set())
|
||||
permitted = await self.get_permitted_actions(db, role)
|
||||
return action in permitted
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# RBAC Phase 2: DB-Driven Permission Code Lookup
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
async def get_role_permissions(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
role_id: int,
|
||||
) -> Set[str]:
|
||||
"""
|
||||
Query the system.role_permissions table joined with system.permissions
|
||||
to retrieve all granted permission codes for a given role_id.
|
||||
|
||||
Returns a set of permission code strings (e.g., {"fleet:view", "user:create"}).
|
||||
Only permissions where granted == True are included.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
role_id: The system.roles.id to look up
|
||||
|
||||
Returns:
|
||||
Set of permission code strings
|
||||
"""
|
||||
stmt = (
|
||||
select(SystemPermission.code)
|
||||
.join(
|
||||
SystemRolePermission,
|
||||
SystemRolePermission.permission_id == SystemPermission.id,
|
||||
)
|
||||
.where(
|
||||
SystemRolePermission.role_id == role_id,
|
||||
SystemRolePermission.granted == True,
|
||||
)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return {row[0] for row in result.all()}
|
||||
|
||||
def invalidate_role_cache(self, role_id: int) -> None:
|
||||
"""
|
||||
Placeholder for cache invalidation when Redis is available.
|
||||
Currently a no-op; structured so caching can be dropped in easily.
|
||||
|
||||
Args:
|
||||
role_id: The role ID whose cache should be invalidated
|
||||
"""
|
||||
logger.debug(f"Cache invalidation requested for role_id={role_id} (no-op, Redis not configured)")
|
||||
pass
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user