jogosultsági szintek RBAC beállítva tesztelve

This commit is contained in:
Roo
2026-06-25 20:41:49 +00:00
parent 52011606ff
commit 36109fc722
242 changed files with 8672 additions and 2237 deletions

View File

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

View File

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

View File

@@ -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}).",
}

View File

@@ -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}"
)

View File

@@ -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",
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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."

View File

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

View File

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

View File

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

View File

@@ -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",
]

View File

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

View File

@@ -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):
"""

View File

@@ -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",
]

View 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})>"
)

View File

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

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

View File

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

View File

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

View File

@@ -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
# ──────────────────────────────────────────────